mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 03:26:12 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019da0f2bf | ||
|
|
5add6a8e19 | ||
|
|
f3c390b89e | ||
|
|
185c3e5136 | ||
|
|
cb863b8ea5 | ||
|
|
695c043e6b | ||
|
|
d53456da3b | ||
|
|
64e930628d | ||
|
|
3c73ce1dc7 |
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Find affected packages
|
||||
id: packages
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
GITHUB_ACTIONS=false bun turbo test --affected
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify published codemode package
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
fi
|
||||
bun turbo verify:package --affected --filter=@opencode-ai/sdk
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
|
||||
@@ -347,7 +347,6 @@
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
@@ -1178,8 +1177,6 @@
|
||||
|
||||
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8pcrQvEQCbvrrQKnD6hclBbI0hUgSrgyADykRbabxv/g9vPurfMC6n23J7dD+KZ3EcCoW+qz3IUIfySJ58gBOg=="],
|
||||
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
||||
|
||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
|
||||
|
||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZgkausouWvO9U4ZtowNJ093bSNYOvH8zqls3uLC3+oxzWvbbTZO8SOdmFk0+gGafsXFJvq2yUX9+rEeJPwOJLw=="],
|
||||
@@ -5910,10 +5907,6 @@
|
||||
|
||||
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-fOM/kGJJ1cipCHQIxioDZEB7NZykpSiqgwm7gIS6THI=",
|
||||
"aarch64-linux": "sha256-XTY2C33HjsBMWO7VIiWc2MynjJrxbTLrOJ+6pM+afI0=",
|
||||
"aarch64-darwin": "sha256-wX6+bC18djtPZ7A9ch+wryM7tDFfrAlT0xx0QTk6EJQ=",
|
||||
"x86_64-darwin": "sha256-dcRRX4bYq5AmG4GcVmYq/M+06dlf4KJHn+clT2JY48g="
|
||||
"x86_64-linux": "sha256-oQnV96kE3lIqsQaaUrH4tiEX8/5xvBWizXMGxayFSHo=",
|
||||
"aarch64-linux": "sha256-Hkl1xdCQ7voAllwtyrm3TjQT9cfej29fvPIP3lH7zVo=",
|
||||
"aarch64-darwin": "sha256-s0WHRB13qcD0KlgWNXO7gLpsDOnfB8xny81GnN5YeQc=",
|
||||
"x86_64-darwin": "sha256-fnYi1AxCrnO3byW9keDfBH2ueCfGZdaweOrV6AerrGs="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ type LoweredInputItem =
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
type OpenResponsesReasoningInput = {
|
||||
type: "reasoning"
|
||||
id: string
|
||||
id?: string
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
@@ -372,9 +372,6 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
// Which lowered input item a persisted item id is about to be attached to.
|
||||
export type ItemKind = "message" | "reasoning" | "function-call" | "hosted-tool"
|
||||
|
||||
export interface Extension {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
@@ -384,10 +381,6 @@ export interface Extension {
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
|
||||
// Optional grammar check applied before a persisted item id is resent as
|
||||
// part of replayed history. Returning false drops the id; every lowered
|
||||
// item treats a dropped id the same as an absent one.
|
||||
readonly acceptsItemID?: (kind: ItemKind, id: string) => boolean
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
@@ -447,53 +440,37 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
|
||||
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
||||
})
|
||||
|
||||
// Servers validate item ids on replayed history, and a malformed or oversized
|
||||
// id can fail an otherwise valid request. Only server-issued tokens are worth
|
||||
// resending; anything else is treated as absent so the item is resent without
|
||||
// an id (or skipped, for items that cannot be expressed without one).
|
||||
const ITEM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
|
||||
// Server-issued item ids need a nonempty prefix and suffix, but the prefix is
|
||||
// provider-defined and does not necessarily identify the item's semantic type.
|
||||
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
|
||||
const metadata = providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) &&
|
||||
typeof metadata.itemId === "string" &&
|
||||
ITEM_ID_PATTERN.test(metadata.itemId)
|
||||
? metadata.itemId
|
||||
: undefined
|
||||
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string") return undefined
|
||||
const separator = metadata.itemId.indexOf("_")
|
||||
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
|
||||
}
|
||||
|
||||
const acceptsItemID = (extension: Extension, kind: ItemKind, id: string | undefined): id is string =>
|
||||
id !== undefined && (extension.acceptsItemID?.(kind, id) ?? true)
|
||||
|
||||
const lowerToolCall = (
|
||||
part: ToolCallPart,
|
||||
providerMetadataKey: string,
|
||||
extension: Extension,
|
||||
): OpenResponsesInputItem => {
|
||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
return {
|
||||
type: "function_call",
|
||||
...(acceptsItemID(extension, "function-call", id) ? { id } : {}),
|
||||
...(id === undefined ? {} : { id }),
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
}
|
||||
}
|
||||
|
||||
const lowerReasoning = (
|
||||
part: ReasoningPart,
|
||||
providerMetadataKey: string,
|
||||
extension: Extension,
|
||||
): OpenResponsesReasoningInput | undefined => {
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined
|
||||
const encryptedContent =
|
||||
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||
? metadata.reasoningEncryptedContent
|
||||
: undefined
|
||||
return {
|
||||
type: "reasoning",
|
||||
id,
|
||||
...(id === undefined ? {} : { id }),
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
}
|
||||
@@ -614,8 +591,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
|
||||
>((groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const rawID = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||
@@ -640,23 +616,23 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||
if (!reasoning) continue
|
||||
const existing = reasoningItems[reasoning.id]
|
||||
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
if (typeof reasoning.encrypted_content === "string")
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
continue
|
||||
}
|
||||
reasoningItems[reasoning.id] = reasoning
|
||||
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
|
||||
input.push(reasoning)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
flushText()
|
||||
if (part.providerExecuted === true) continue
|
||||
input.push(lowerToolCall(part, providerMetadataKey, extension))
|
||||
input.push(lowerToolCall(part, providerMetadataKey))
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
@@ -668,7 +644,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
: Schema.is(HostedToolItem)(part.result.value)
|
||||
? part.result.value
|
||||
: extension.lowerHostedToolItem?.(part.result.value)
|
||||
if (acceptsItemID(extension, "hosted-tool", id) && hosted?.id === id) {
|
||||
if (id !== undefined && hosted?.id === id) {
|
||||
if (!hostedToolItems.has(id)) {
|
||||
input.push(hosted)
|
||||
hostedToolItems.add(id)
|
||||
|
||||
@@ -86,28 +86,10 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
// Replayed items are paired with stored server state by id, so a foreign or
|
||||
// synthetic token can fail request validation even when `call_id` pairing is
|
||||
// intact. Only resend ids in each item kind's own grammar; hosted tool
|
||||
// items keep generic validation because every hosted tool mints its own
|
||||
// prefix. The same allowlist approach codex uses before resending history
|
||||
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
|
||||
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
|
||||
message: ["msg_"],
|
||||
reasoning: ["rs_"],
|
||||
"function-call": ["fc_"],
|
||||
// Every hosted tool mints its own id prefix, so items keep generic validation.
|
||||
"hosted-tool": [],
|
||||
}
|
||||
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
|
||||
const prefixes = ITEM_ID_PREFIXES[kind]
|
||||
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
|
||||
},
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("deepinfra")
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
id: "deepinfra-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.deepinfra.baseURL },
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const root = baseURL?.replace(/\/+$/, "")
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: {
|
||||
baseURL: root === undefined ? profiles.deepinfra.baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
|
||||
},
|
||||
auth: AuthOptions.bearer(input, "DEEPINFRA_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
@@ -6,6 +6,7 @@ export * as Azure from "./azure.js"
|
||||
export * as Cerebras from "./cerebras.js"
|
||||
export * as Cloudflare from "./cloudflare.js"
|
||||
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare.js"
|
||||
export * as DeepInfra from "./deepinfra.js"
|
||||
export * as Google from "./google.js"
|
||||
export * as GoogleVertex from "./google-vertex.js"
|
||||
export * as GoogleVertexChat from "./google-vertex-chat.js"
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "deepinfra",
|
||||
"route": "deepinfra-chat",
|
||||
"transport": "http",
|
||||
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "text", "golden"],
|
||||
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-text",
|
||||
"recordedAt": "2026-08-26T00:34:03.019Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"!\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":25,\"total_tokens\":28,\"completion_tokens\":3,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":25,\"total_tokens\":28,\"completion_tokens\":3,\"estimated_cost\":3.46e-6,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "deepinfra",
|
||||
"route": "deepinfra-chat",
|
||||
"transport": "http",
|
||||
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "tool", "tool-call", "golden"],
|
||||
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-tool-call",
|
||||
"recordedAt": "2026-08-26T00:34:04.173Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_SMfBjXa8eCmHLyjfeyARxe3a\",\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\",\"name\":\"get_weather\"},\"type\":\"function\"}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":230,\"total_tokens\":244,\"completion_tokens\":14,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":230,\"total_tokens\":244,\"completion_tokens\":14,\"estimated_cost\":0.000027480000000000005,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "deepinfra",
|
||||
"route": "deepinfra-chat",
|
||||
"transport": "http",
|
||||
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "tool", "tool-loop", "golden"],
|
||||
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-tool-loop",
|
||||
"recordedAt": "2026-08-26T00:34:05.656Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_W3stxe7FNHozlB4tDxlTVZou\",\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\",\"name\":\"get_weather\"},\"type\":\"function\"}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":243,\"total_tokens\":257,\"completion_tokens\":14,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":243,\"total_tokens\":257,\"completion_tokens\":14,\"estimated_cost\":0.000028780000000000002,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_W3stxe7FNHozlB4tDxlTVZou\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning_content\":\"\"},{\"role\":\"tool\",\"tool_call_id\":\"call_W3stxe7FNHozlB4tDxlTVZou\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Paris\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" is\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" sunny\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\".\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":276,\"total_tokens\":281,\"completion_tokens\":5,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":276,\"total_tokens\":281,\"completion_tokens\":5,\"estimated_cost\":0.0000292,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -28,6 +28,7 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
|
||||
import("@opencode-ai/ai/providers/togetherai"),
|
||||
import("@opencode-ai/ai/providers/cerebras"),
|
||||
import("@opencode-ai/ai/providers/deepinfra"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
@@ -37,6 +38,24 @@ describe("provider package entrypoints", () => {
|
||||
expect(modules[19].model).toBe(modules[20].model)
|
||||
})
|
||||
|
||||
test("maps DeepInfra package settings onto its native executable model", async () => {
|
||||
const DeepInfra = await import("@opencode-ai/ai/providers/deepinfra")
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://provider.example.test/v1/",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "high" as const },
|
||||
}
|
||||
const deepinfra = DeepInfra.model("google/gemma-3-27b-it", settings)
|
||||
|
||||
expect(deepinfra.route.id).toBe("deepinfra-chat")
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://provider.example.test/v1/openai")
|
||||
expect(deepinfra.route.defaults.providerOptions).toEqual(settings.providerOptions)
|
||||
expect(deepinfra.route.defaults.headers).toEqual(settings.headers)
|
||||
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
|
||||
})
|
||||
|
||||
test("maps OpenRouter and xAI package settings onto executable models", async () => {
|
||||
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
|
||||
const XAI = await import("@opencode-ai/ai/providers/xai")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
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 { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
@@ -83,6 +83,39 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays reasoning with Mantle's message-prefixed item ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).responses("openai.gpt-oss-120b")
|
||||
const item = { type: "reasoning", id: "msg_95d4d0af4350432a", encrypted_content: "mantle-state" }
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: item.id, delta: "Considering." },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message, Message.user("Continue.")] }),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "msg_95d4d0af4350432a",
|
||||
summary: [{ type: "summary_text", text: "Considering." }],
|
||||
encrypted_content: "mantle-state",
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Anthropic from "../../src/providers/anthropic.js"
|
||||
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible.js"
|
||||
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
|
||||
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
|
||||
import * as Google from "../../src/providers/google.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
@@ -55,6 +55,9 @@ const cerebras = Cerebras.configure({ apiKey: process.env.CEREBRAS_API_KEY ?? "f
|
||||
const groq = OpenAICompatible.groq
|
||||
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
|
||||
.model("llama-3.3-70b-versatile")
|
||||
const deepInfra = DeepInfra.configure({ apiKey: process.env.DEEPINFRA_API_KEY ?? "fixture" }).model(
|
||||
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
)
|
||||
const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
|
||||
const openrouter = openRouter.model("openai/gpt-4o-mini")
|
||||
const openrouterGpt55 = openRouter.model("openai/gpt-5.5")
|
||||
@@ -222,6 +225,13 @@ describeRecordedGoldenScenarios([
|
||||
requires: ["GROQ_API_KEY"],
|
||||
scenarios: ["text", "tool-call", { id: "tool-loop", timeout: 30_000 }],
|
||||
},
|
||||
{
|
||||
name: "DeepInfra Llama 3.3 70B",
|
||||
prefix: "deepinfra-chat",
|
||||
model: deepInfra,
|
||||
requires: ["DEEPINFRA_API_KEY"],
|
||||
scenarios: ["text", "tool-call", { id: "tool-loop", timeout: 30_000 }],
|
||||
},
|
||||
{
|
||||
name: "OpenRouter gpt-4o-mini",
|
||||
prefix: "openai-compatible-chat",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
|
||||
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
@@ -29,6 +29,18 @@ describe("native OpenAI-compatible providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves native DeepInfra provider and route identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
|
||||
expect(deepinfra).toMatchObject({
|
||||
provider: "deepinfra",
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
|
||||
route: { id: "deepinfra-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const together = yield* compileRequest(
|
||||
@@ -82,6 +94,21 @@ describe("native OpenAI-compatible providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes DeepInfra API roots without duplicating the OpenAI path", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const baseURL of [
|
||||
"https://gateway.example/v1",
|
||||
"https://gateway.example/v1/",
|
||||
"https://gateway.example/v1/openai",
|
||||
"https://gateway.example/v1/openai/",
|
||||
]) {
|
||||
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
|
||||
"https://gateway.example/v1/openai",
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps package settings onto native executable models", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const native of [TogetherAI, Cerebras]) {
|
||||
@@ -122,6 +149,12 @@ describe("native OpenAI-compatible providers", () => {
|
||||
token: "cerebras-secret",
|
||||
url: "https://api.cerebras.ai/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: DeepInfra.configure().model("gemma"),
|
||||
env: { DEEPINFRA_API_KEY: "deepinfra-secret" },
|
||||
token: "deepinfra-secret",
|
||||
url: "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
},
|
||||
]
|
||||
|
||||
yield* Effect.forEach(scenarios, (scenario) =>
|
||||
|
||||
@@ -195,15 +195,19 @@ describe("Open Responses-compatible route", () => {
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
// The baseline does not enforce a provider id grammar, so a
|
||||
// non-OpenAI but well-formed token is resent as-is.
|
||||
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
|
||||
// Shape violations are dropped even without a grammar policy.
|
||||
{
|
||||
type: "text",
|
||||
text: "Dropped.",
|
||||
providerMetadata: { openresponses: { itemId: `m${"a".repeat(64)}` } },
|
||||
text: "Long.",
|
||||
providerMetadata: { openresponses: { itemId: `history_${"a".repeat(64)}` } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Opaque.",
|
||||
providerMetadata: { openresponses: { itemId: "provider_value/with+symbols" } },
|
||||
},
|
||||
{ type: "text", text: "No suffix.", providerMetadata: { openresponses: { itemId: "msg_" } } },
|
||||
{ type: "text", text: "No prefix.", providerMetadata: { openresponses: { itemId: "_item" } } },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -218,8 +222,23 @@ describe("Open Responses-compatible route", () => {
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: `history_${"a".repeat(64)}`,
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Dropped." }],
|
||||
content: [{ type: "output_text", text: "Long." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "provider_value/with+symbols",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Opaque." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "output_text", text: "No suffix." },
|
||||
{ type: "output_text", text: "No prefix." },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -3150,38 +3150,50 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops replayed item ids outside the server's grammar", () =>
|
||||
it.effect("preserves provider-issued item ids and removes malformed ids without dropping items", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
// Fails the message id prefix.
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
providerMetadata: { openai: { itemId: "history_1" } },
|
||||
},
|
||||
// Oversized for the Responses item id limit.
|
||||
{
|
||||
type: "text",
|
||||
text: "World",
|
||||
providerMetadata: { openai: { itemId: `m${"a".repeat(64)}` } },
|
||||
providerMetadata: { openai: { itemId: `message_${"a".repeat(64)}` } },
|
||||
},
|
||||
// Fails the reasoning id prefix, so the whole item is unreplayable
|
||||
// statelessly and is skipped rather than sent malformed.
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "thinking_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Missing suffix.",
|
||||
providerMetadata: { openai: { itemId: "rs_", reasoningEncryptedContent: "another-state" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "No prefix separator.",
|
||||
providerMetadata: { openai: { itemId: "550e8400-e29b-41d4-a716-446655440000" } },
|
||||
},
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { openai: { itemId: "toolu_01A" } },
|
||||
}),
|
||||
ToolCallPart.make({
|
||||
id: "call_2",
|
||||
name: "lookup",
|
||||
input: { query: "news" },
|
||||
providerMetadata: { openai: { itemId: "fc_" } },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -3190,18 +3202,44 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "history_1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "output_text", text: "Hello" },
|
||||
{ type: "output_text", text: "World" },
|
||||
],
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: `message_${"a".repeat(64)}`,
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "World" }],
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "thinking_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
encrypted_content: "encrypted-state",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "Missing suffix." }],
|
||||
encrypted_content: "another-state",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "No prefix separator." }],
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
id: "toolu_01A",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_2",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"news"}',
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Virtualizer } from "@tanstack/solid-virtual"
|
||||
|
||||
export function remeasureTimeline(
|
||||
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>,
|
||||
content: HTMLDivElement | undefined,
|
||||
) {
|
||||
virtualizer.measure()
|
||||
virtualizer.getVirtualItems()
|
||||
content?.querySelectorAll<HTMLDivElement>("[data-index]").forEach((element) => virtualizer.measureElement(element))
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { createStore } from "solid-js/store"
|
||||
import type { createTimelineProjection } from "./projection"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { remeasureTimeline } from "./measurement"
|
||||
|
||||
const fallbackItemSize = 60
|
||||
const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
|
||||
@@ -237,7 +238,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const key = input.sessionKey()
|
||||
if (measuredSessionKey !== key) {
|
||||
measuredSessionKey = key
|
||||
virtualizer.measure()
|
||||
remeasureTimeline(virtualizer, virtualContent)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { LocationProvider } from "@/workspaces/location"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { createEditProjectModel } from "./project-model"
|
||||
import { ProjectSettingsExtensions } from "./project-extensions"
|
||||
import { SettingsServerDataScope } from "@/settings/server-scope"
|
||||
@@ -57,11 +58,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
|
||||
<Tabs.List>
|
||||
<div class="project-settings-nav">
|
||||
<Tabs.Trigger value="general">
|
||||
<ProjectAvatar
|
||||
fallback={projectName()}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
class="!size-4 shrink-0"
|
||||
/>
|
||||
<ProjectIcon project={props.project} class="!size-4 shrink-0" />
|
||||
<span class="truncate">{projectName()}</span>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="scripts">
|
||||
@@ -114,14 +111,14 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
<ProjectIcon
|
||||
project={props.project}
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
src={getProjectAvatarSource(props.project.id, {
|
||||
icon={{
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
variant={getProjectAvatarVariant(model.store.color)}
|
||||
}}
|
||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { InlineServerSelect } from "@/settings/server-select"
|
||||
import { DialogEditProject } from "./project-dialog"
|
||||
import "@/settings/settings.css"
|
||||
@@ -40,14 +39,13 @@ export const SettingsProjects: Component = () => {
|
||||
|
||||
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
|
||||
const name = () => displayName(props.project)
|
||||
const color = () => getProjectAvatarVariant(props.project.icon?.color)
|
||||
return (
|
||||
<div
|
||||
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
|
||||
onClick={() => openProjectSettings(props.project, props.server)}
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
|
||||
<ProjectIcon project={props.project} class="shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ProjectAvatar, type ProjectAvatarProps } from "@opencode-ai/ui/project-avatar"
|
||||
import { splitProps } from "solid-js"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
|
||||
|
||||
type ProjectIconProps = Omit<ProjectAvatarProps, "fallback" | "src" | "variant"> & {
|
||||
project: LocalProject
|
||||
fallback?: string
|
||||
icon?: LocalProject["icon"]
|
||||
}
|
||||
|
||||
export function ProjectIcon(props: ProjectIconProps) {
|
||||
const [local, rest] = splitProps(props, ["project", "fallback", "icon"])
|
||||
const icon = () => local.icon ?? local.project.icon
|
||||
|
||||
return (
|
||||
<ProjectAvatar
|
||||
{...rest}
|
||||
fallback={local.fallback ?? displayName(local.project)}
|
||||
src={getProjectAvatarSource(local.project.id, icon())}
|
||||
variant={getProjectAvatarVariant(icon()?.color)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { filterVirtualIndexes } from "@/session/timeline/virtual-items"
|
||||
import { remeasureTimeline } from "@/session/timeline/measurement"
|
||||
|
||||
test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => {
|
||||
const writes: { offset: number; adjustments?: number }[] = []
|
||||
@@ -139,6 +140,40 @@ test("explicit measurement refreshes a cached row size with a custom measurer",
|
||||
expect(virtualizer.getTotalSize()).toBe(120)
|
||||
})
|
||||
|
||||
test("remeasuring a moved session preserves the actual heights of mounted rows", () => {
|
||||
const root = document.createElement("div")
|
||||
const content = document.createElement("div")
|
||||
root.appendChild(content)
|
||||
const heights = [120, 180, 240]
|
||||
heights.forEach((height, index) => {
|
||||
const element = document.createElement("div")
|
||||
element.dataset.index = String(index)
|
||||
Object.defineProperty(element, "offsetHeight", { value: height })
|
||||
content.appendChild(element)
|
||||
})
|
||||
|
||||
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: heights.length,
|
||||
estimateSize: () => 60,
|
||||
initialRect: { width: 400, height: 600 },
|
||||
getScrollElement: () => root,
|
||||
scrollToFn: () => {},
|
||||
observeElementRect: () => {},
|
||||
observeElementOffset: () => {},
|
||||
measureElement: (element) => element.offsetHeight,
|
||||
})
|
||||
|
||||
virtualizer._willUpdate()
|
||||
virtualizer.getTotalSize()
|
||||
content.querySelectorAll<HTMLDivElement>("[data-index]").forEach((element) => virtualizer.measureElement(element))
|
||||
expect(virtualizer.getTotalSize()).toBe(540)
|
||||
|
||||
remeasureTimeline(virtualizer, content)
|
||||
|
||||
expect(heights.map((_, index) => virtualizer.itemSizeCache.get(index))).toEqual(heights)
|
||||
expect(virtualizer.getTotalSize()).toBe(540)
|
||||
})
|
||||
|
||||
test("initial rect projects rows before a scroll element connects", () => {
|
||||
createRoot((dispose) => {
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
|
||||
@@ -476,7 +476,7 @@ export type PromptFileAttachment = {
|
||||
|
||||
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
|
||||
|
||||
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
|
||||
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
|
||||
|
||||
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
|
||||
|
||||
@@ -2740,6 +2740,7 @@ export type SessionImportInput = {
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly text?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly type: "user"
|
||||
@@ -3015,6 +3016,7 @@ export type SessionImportInput = {
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly text?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly type: "user"
|
||||
@@ -3290,6 +3292,7 @@ export type SessionImportInput = {
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly text?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly type: "user"
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
|
||||
@@ -54,9 +54,10 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/cerebras":
|
||||
case "@ai-sdk/deepinfra":
|
||||
case "@ai-sdk/togetherai":
|
||||
return {
|
||||
package: `@opencode-ai/ai/providers/${input.packageName === "@ai-sdk/cerebras" ? "cerebras" : "togetherai"}`,
|
||||
package: `@opencode-ai/ai/providers/${input.packageName.slice("@ai-sdk/".length)}`,
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
|
||||
@@ -334,6 +334,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@ai-sdk/openai" ||
|
||||
name === "@ai-sdk/anthropic" ||
|
||||
name === "@ai-sdk/cerebras" ||
|
||||
name === "@ai-sdk/deepinfra" ||
|
||||
name === "@ai-sdk/openai-compatible" ||
|
||||
name === "@ai-sdk/google" ||
|
||||
name === "@ai-sdk/togetherai" ||
|
||||
@@ -345,6 +346,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@opencode-ai/ai/providers/anthropic" ||
|
||||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/cerebras" ||
|
||||
name === "@opencode-ai/ai/providers/deepinfra" ||
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/togetherai" ||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { CerebrasPlugin } from "./provider/cerebras.js"
|
||||
import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway.js"
|
||||
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai.js"
|
||||
import { CoherePlugin } from "./provider/cohere.js"
|
||||
import { DeepInfraPlugin } from "./provider/deepinfra.js"
|
||||
import { DynamicProviderPlugin } from "./provider/dynamic.js"
|
||||
import { GatewayPlugin } from "./provider/gateway.js"
|
||||
import { GithubCopilotPlugin } from "./provider/github-copilot.js"
|
||||
@@ -42,7 +41,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
CloudflareAIGatewayPlugin,
|
||||
CloudflareWorkersAIPlugin,
|
||||
CoherePlugin,
|
||||
DeepInfraPlugin,
|
||||
GatewayPlugin,
|
||||
GithubCopilotPlugin,
|
||||
GitLabPlugin,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createProviderPlugin } from "./factory.js"
|
||||
|
||||
export const DeepInfraPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.deepinfra",
|
||||
package: "@ai-sdk/deepinfra",
|
||||
load: async (options) => {
|
||||
const { createDeepInfra } = await import("@ai-sdk/deepinfra")
|
||||
return createDeepInfra(options)
|
||||
},
|
||||
})
|
||||
@@ -108,7 +108,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
|
||||
@@ -49,6 +49,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
|
||||
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
|
||||
["@opencode-ai/ai/providers/cerebras", () => import("@opencode-ai/ai/providers/cerebras")],
|
||||
["@opencode-ai/ai/providers/deepinfra", () => import("@opencode-ai/ai/providers/deepinfra")],
|
||||
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
|
||||
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
|
||||
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
|
||||
|
||||
@@ -596,7 +596,11 @@ const layer = Layer.effect(
|
||||
yield* plugins.flush
|
||||
return yield* Image.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skills = Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Skill.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const prompt = yield* resolvePrompt(
|
||||
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
||||
image,
|
||||
@@ -718,7 +722,7 @@ const layer = Layer.effect(
|
||||
skill: Effect.fn("Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skill = (yield* skills.list()).find((item) => item.id === input.skill)
|
||||
const skill = yield* skills.get(input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* bus.publish(
|
||||
SessionEvent.Skill.Activated,
|
||||
@@ -970,16 +974,22 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
|
||||
const selected = yield* Effect.gen(function* () {
|
||||
if (!requested?.length) return undefined
|
||||
const skillService = yield* skills
|
||||
const available = yield* skillService.list()
|
||||
return yield* Effect.forEach(requested, (attachment) => {
|
||||
const skill = available.find((item) => item.id === attachment.id)
|
||||
if (!skill) return Effect.fail(new SkillNotFoundError({ skill: attachment.id }))
|
||||
return Effect.succeed({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
mention: attachment.mention,
|
||||
})
|
||||
})
|
||||
const prepared = new Map<Skill.ID, Skill.Name>()
|
||||
return yield* Effect.forEach(requested, (attachment) =>
|
||||
Effect.gen(function* () {
|
||||
const name = prepared.get(attachment.id)
|
||||
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
|
||||
const skill = yield* skillService.get(attachment.id)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
|
||||
prepared.set(skill.id, skill.name)
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
|
||||
mention: attachment.mention,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
|
||||
})
|
||||
|
||||
@@ -139,7 +139,11 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
(file) =>
|
||||
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
|
||||
) ?? []
|
||||
return [`[User]: ${message.text}`, ...files].join("\n")
|
||||
const skills =
|
||||
message.skills?.flatMap((skill) =>
|
||||
skill.text === undefined ? [] : [`[Skill activated: ${skill.name}]\n${skill.text}`],
|
||||
) ?? []
|
||||
return [...skills, `[User]: ${message.text}`, ...files].join("\n")
|
||||
}
|
||||
if (message.type === "location-switched")
|
||||
return `[User]: The working directory has been changed to ${message.location.directory}.`
|
||||
|
||||
@@ -236,6 +236,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
]
|
||||
case "user":
|
||||
const content = [
|
||||
...(message.skills ?? []).flatMap((skill) => (skill.text === undefined ? [] : [Message.text(skill.text)])),
|
||||
...(message.text === "" ? [] : [Message.text(message.text)]),
|
||||
...userAttachmentContent(message.files ?? []),
|
||||
]
|
||||
|
||||
@@ -220,6 +220,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
|
||||
skills: message.skills?.map((skill, index) => ({
|
||||
...skill,
|
||||
name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
|
||||
text: skill.text === undefined ? undefined : redact("skill", String(index), skill.text),
|
||||
mention: skill.mention
|
||||
? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
|
||||
: undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as Skill from "./skill.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
@@ -52,6 +53,21 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interface, skill: Info) {
|
||||
const directory = path.dirname(skill.location)
|
||||
const files =
|
||||
path.basename(skill.location) === "SKILL.md"
|
||||
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
|
||||
.filter((file) => path.basename(file) !== "SKILL.md")
|
||||
.toSorted()
|
||||
.slice(0, 10)
|
||||
: []
|
||||
return {
|
||||
directory,
|
||||
output: toModelOutput(skill, files),
|
||||
}
|
||||
})
|
||||
|
||||
export type Data = {
|
||||
skills: Map<ID, Types.DeepMutable<Info>>
|
||||
}
|
||||
@@ -64,6 +80,7 @@ export type Draft = {
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
@@ -98,6 +115,9 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("Skill.get")(function* (id) {
|
||||
return state.get().skills.get(id)
|
||||
}),
|
||||
list: Effect.fn("Skill.list")(function* () {
|
||||
return Array.from(state.get().skills.values())
|
||||
}),
|
||||
|
||||
@@ -26,7 +26,6 @@ const render = (skills: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"When the user references a skill with @skill-id, load that skill with the skill tool.",
|
||||
...(skills.length === 0
|
||||
? ["No skills are currently available."]
|
||||
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SkillTool from "./skill.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -9,7 +8,6 @@ import { Skill } from "../../skill.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
|
||||
export const name = "skill"
|
||||
const FILE_LIMIT = 10
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }),
|
||||
@@ -47,8 +45,7 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
const skill = current.find((skill) => skill.id === input.id)
|
||||
const skill = yield* skills.get(input.id)
|
||||
if (!skill) return yield* unableToLoad(input.id)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
@@ -59,19 +56,7 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
const directory = path.dirname(skill.location)
|
||||
const files =
|
||||
path.basename(skill.location) === "SKILL.md"
|
||||
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
|
||||
.filter((file) => path.basename(file) !== "SKILL.md")
|
||||
.toSorted()
|
||||
.slice(0, FILE_LIMIT)
|
||||
: []
|
||||
return {
|
||||
name: skill.name,
|
||||
directory,
|
||||
output: Skill.toModelOutput(skill, files),
|
||||
}
|
||||
return { name: skill.name, ...(yield* Skill.prepare(fs, skill)) }
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
|
||||
@@ -61,8 +61,8 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Cerebras and Together AI settings, headers, and reasoning options to native providers", () => {
|
||||
for (const name of ["cerebras", "togetherai"]) {
|
||||
test("maps Cerebras, DeepInfra, and Together AI settings, headers, and reasoning options to native providers", () => {
|
||||
for (const name of ["cerebras", "deepinfra", "togetherai"]) {
|
||||
expect(
|
||||
map(`@ai-sdk/${name}`, {
|
||||
apiKey: "secret",
|
||||
|
||||
@@ -885,6 +885,12 @@ describe("ModelResolver", () => {
|
||||
{ reasoningEffort: "high" },
|
||||
{ reasoningEffort: "high" },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/deepinfra",
|
||||
"@opencode-ai/ai/providers/deepinfra",
|
||||
{ reasoningEffort: "none" },
|
||||
{ reasoningEffort: "none" },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/openai-compatible",
|
||||
"@opencode-ai/ai/providers/openai-compatible",
|
||||
@@ -963,6 +969,7 @@ describe("ModelResolver", () => {
|
||||
],
|
||||
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
|
||||
["@ai-sdk/cerebras", "@opencode-ai/ai/providers/cerebras", "api-model"],
|
||||
["@ai-sdk/deepinfra", "@opencode-ai/ai/providers/deepinfra", "api-model"],
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
|
||||
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
|
||||
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
|
||||
@@ -1087,6 +1094,11 @@ describe("ModelResolver", () => {
|
||||
const cerebras = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/cerebras"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
const deepinfra = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/deepinfra"), {
|
||||
settings: { baseURL: "https://deepinfra.example/provider-root", reasoningEffort: "none" },
|
||||
}),
|
||||
)
|
||||
const togetherai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/togetherai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
@@ -1113,6 +1125,10 @@ describe("ModelResolver", () => {
|
||||
expect(cerebras.route.id).toBe("cerebras-chat")
|
||||
expect(cerebras.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(String(cerebras.provider)).toBe("test-provider")
|
||||
expect(deepinfra.route.id).toBe("deepinfra-chat")
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://deepinfra.example/provider-root/openai")
|
||||
expect(deepinfra.route.defaults.providerOptions).toEqual({ reasoningEffort: "none" })
|
||||
expect(String(deepinfra.provider)).toBe("test-provider")
|
||||
expect(togetherai.route.id).toBe("togetherai-chat")
|
||||
expect(togetherai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(String(togetherai.provider)).toBe("test-provider")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -221,6 +222,47 @@ describe("Npm.add", () => {
|
||||
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const repository = pathToFileURL(fixture.repository).href
|
||||
const mutable = `git+${repository}#fixture-branch`
|
||||
const pinned = `git+${repository}#${fixture.commit}`
|
||||
|
||||
const first = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const mutableEntry = yield* npm.add(mutable, { refresh: true })
|
||||
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
await Bun.$`git -C ${fixture.repository} add .`
|
||||
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
|
||||
})
|
||||
yield* npm.add(mutable, { refresh: true })
|
||||
return { mutable: mutableEntry, pinned: pinnedEntry }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(await Bun.file(path.join(first.mutable.directory, "index.js")).text()).toContain("root: true")
|
||||
expect(await Bun.file(path.join(first.pinned.directory, "index.js")).text()).toContain("root: true")
|
||||
|
||||
const second = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
mutable: yield* npm.add(mutable, { refresh: true }),
|
||||
pinned: yield* npm.add(pinned, { refresh: true }),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(await Bun.file(path.join(second.mutable.directory, "index.js")).text()).toContain('root: "second"')
|
||||
expect(await Bun.file(path.join(second.pinned.directory, "index.js")).text()).toContain("root: true")
|
||||
|
||||
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
|
||||
const offline = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(mutable, { refresh: true })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
|
||||
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
|
||||
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
|
||||
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
|
||||
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
|
||||
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
|
||||
@@ -21,7 +20,6 @@ const options = { name: "custom-provider", apiKey: "test", baseURL: "https://exa
|
||||
const providers = [
|
||||
{ id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" },
|
||||
{ id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" },
|
||||
{ id: "deepinfra", plugin: DeepInfraPlugin, package: "@ai-sdk/deepinfra", provider: "deepinfra.chat" },
|
||||
{ id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" },
|
||||
{ id: "groq", plugin: GroqPlugin, package: "@ai-sdk/groq", provider: "groq.chat" },
|
||||
{ id: "mistral", plugin: MistralPlugin, package: "@ai-sdk/mistral", provider: "mistral.chat" },
|
||||
|
||||
@@ -6,6 +6,7 @@ describe("Provider", () => {
|
||||
test("loads bundled native provider entrypoints", async () => {
|
||||
const packages = [
|
||||
"@opencode-ai/ai/providers/cerebras",
|
||||
"@opencode-ai/ai/providers/deepinfra",
|
||||
"@opencode-ai/ai/providers/google-vertex",
|
||||
"@opencode-ai/ai/providers/google-vertex/gemini",
|
||||
"@opencode-ai/ai/providers/google-vertex/chat",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -231,6 +232,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user" as const,
|
||||
text: "Manual compaction should include this short conversation.",
|
||||
skills: [
|
||||
{
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
text: "Use Effect services and generators.",
|
||||
},
|
||||
],
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}
|
||||
const session = yield* insertSession(sessionID, { parent_id: parentID })
|
||||
@@ -261,6 +269,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
})
|
||||
expect(requests[0]?.generation).toBeUndefined()
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
||||
])
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "path"
|
||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -1178,6 +1179,13 @@ describe("SessionTransfer", () => {
|
||||
id: sourceMessageID,
|
||||
type: "user",
|
||||
text: "Imported message",
|
||||
skills: [
|
||||
{
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
text: "Private skill instructions from /private/project",
|
||||
},
|
||||
],
|
||||
time: { created: DateTime.makeUnsafe(100) },
|
||||
},
|
||||
{
|
||||
@@ -1207,7 +1215,11 @@ describe("SessionTransfer", () => {
|
||||
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
|
||||
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
|
||||
expect(sanitized.messages).toMatchObject([
|
||||
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
|
||||
{
|
||||
id: sourceMessageID,
|
||||
text: `[redacted:text:${sourceMessageID}]`,
|
||||
skills: [{ id: "effect", name: "[redacted:skill-name:0]", text: "[redacted:skill:0]" }],
|
||||
},
|
||||
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
|
||||
])
|
||||
|
||||
|
||||
@@ -205,6 +205,44 @@ Recent work
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers each prepared skill once before the prompt", () => {
|
||||
const effect = SkillAttachment.make({
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
text: "<skill_content>Use Effect</skill_content>",
|
||||
})
|
||||
const api = SkillAttachment.make({
|
||||
id: Skill.ID.make("api-design"),
|
||||
name: Skill.Name.make("API design"),
|
||||
text: "<skill_content>Design APIs</skill_content>",
|
||||
})
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-skill-content"),
|
||||
type: "user",
|
||||
text: "Use @effect and @api-design",
|
||||
skills: [effect, api, SkillAttachment.make({ id: effect.id, name: effect.name })],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages).toEqual([
|
||||
Message.make({
|
||||
id: id("user-skill-content"),
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "<skill_content>Use Effect</skill_content>" },
|
||||
{ type: "text", text: "<skill_content>Design APIs</skill_content>" },
|
||||
{ type: "text", text: "Use @effect and @api-design" },
|
||||
],
|
||||
metadata: {},
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("does not inject skill content for reference-only attachments", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -23,18 +24,20 @@ const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const projects = Layer.mock(Project.Service, {
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
})
|
||||
const skills = Layer.mock(Skill.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
description: "Effect guidance",
|
||||
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
||||
content: "Use Effect",
|
||||
}),
|
||||
]),
|
||||
const info = Skill.Info.make({
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
description: "Effect guidance",
|
||||
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
|
||||
content: "Use Effect",
|
||||
})
|
||||
const skills = Layer.merge(
|
||||
Layer.mock(Skill.Service, {
|
||||
get: (id) => Effect.succeed(id === info.id ? info : undefined),
|
||||
list: () => Effect.succeed([info]),
|
||||
}),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
@@ -56,7 +59,7 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
describe("Session.skill", () => {
|
||||
it.effect("keeps skill mentions as references on a normal prompt", () =>
|
||||
it.effect("materializes mentioned skills on their owning prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
@@ -67,26 +70,63 @@ describe("Session.skill", () => {
|
||||
yield* sessions.prompt({
|
||||
id,
|
||||
sessionID: session.id,
|
||||
text: "Apply @effect",
|
||||
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }],
|
||||
text: "Apply @effect and @effect",
|
||||
skills: [
|
||||
{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } },
|
||||
{ id: Skill.ID.make("effect"), mention: { start: 18, end: 25, text: "@effect" } },
|
||||
],
|
||||
resume: false,
|
||||
})
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([])
|
||||
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
|
||||
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([
|
||||
expect.objectContaining({
|
||||
id,
|
||||
type: "user",
|
||||
text: "Apply @effect",
|
||||
text: "Apply @effect and @effect",
|
||||
skills: [
|
||||
{
|
||||
id: "effect",
|
||||
name: "Effect",
|
||||
text: Skill.toModelOutput(info, []),
|
||||
mention: { start: 6, end: 13, text: "@effect" },
|
||||
},
|
||||
{
|
||||
id: "effect",
|
||||
name: "Effect",
|
||||
mention: { start: 18, end: 25, text: "@effect" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("excludes mentioned skills when forking before their prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const session = yield* sessions.create({ location })
|
||||
const initial = SessionMessage.ID.make("msg_before_skill_attachment")
|
||||
const selected = SessionMessage.ID.make("msg_fork_skill_attachment")
|
||||
|
||||
yield* sessions.prompt({ id: initial, sessionID: session.id, text: "Before the skill", resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
|
||||
yield* sessions.prompt({
|
||||
id: selected,
|
||||
sessionID: session.id,
|
||||
text: "Apply @effect",
|
||||
skills: [{ id: info.id, mention: { start: 6, end: 13, text: "@effect" } }],
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
|
||||
const forked = yield* sessions.fork({ sessionID: session.id, boundary: { type: "before", messageID: selected } })
|
||||
|
||||
expect(yield* sessions.messages({ sessionID: forked.id })).toEqual([
|
||||
expect.objectContaining({ type: "user", text: "Before the skill" }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ describe("Skill", () => {
|
||||
})
|
||||
|
||||
expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")])
|
||||
expect(yield* skill.get(Skill.ID.make("review"))).toEqual(info("review", "Second"))
|
||||
expect(yield* skill.get(Skill.ID.make("missing"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ describe("SkillInstructions", () => {
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"When the user references a skill with @skill-id, load that skill with the skill tool.",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <id>effect</id>",
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("SkillTool", () => {
|
||||
Skill.Service.of({
|
||||
transform: (_transform) => Effect.die("unused"),
|
||||
reload: () => Effect.die("unused"),
|
||||
get: (id) => Effect.succeed(current.find((skill) => skill.id === id)),
|
||||
list: () => Effect.succeed(current),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -16200,6 +16200,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface SkillAttachment extends Schema.Schema.Type<typeof SkillAttachme
|
||||
export const SkillAttachment = Schema.Struct({
|
||||
id: Skill.ID,
|
||||
name: Skill.Name,
|
||||
text: Schema.String.pipe(optional),
|
||||
mention: PromptMention.pipe(optional),
|
||||
}).annotate({ identifier: "Prompt.SkillAttachment" })
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Form } from "../src/form.js"
|
||||
import { Mcp } from "../src/mcp.js"
|
||||
import { Model } from "../src/model.js"
|
||||
import { Project } from "../src/project.js"
|
||||
import { SkillAttachment } from "../src/prompt.js"
|
||||
import { Provider } from "../src/provider.js"
|
||||
import { Pty } from "../src/pty.js"
|
||||
import { Session } from "../src/session.js"
|
||||
@@ -79,6 +80,16 @@ describe("contract hygiene", () => {
|
||||
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
|
||||
})
|
||||
|
||||
test("skill attachments retain legacy references while accepting prepared instructions", () => {
|
||||
const reference = { id: Skill.ID.make("effect"), name: Skill.Name.make("Effect") }
|
||||
expect(Schema.decodeUnknownSync(SkillAttachment)(reference)).toEqual(reference)
|
||||
expect(Schema.encodeSync(SkillAttachment)({ ...reference, text: undefined })).toEqual(reference)
|
||||
expect(Schema.decodeUnknownSync(SkillAttachment)({ ...reference, text: "Use Effect" })).toEqual({
|
||||
...reference,
|
||||
text: "Use Effect",
|
||||
})
|
||||
})
|
||||
|
||||
test("session inbox items omit the internal enqueue sequence", () => {
|
||||
expect(
|
||||
Schema.encodeSync(SessionInbox.Info)(
|
||||
|
||||
@@ -86,7 +86,15 @@ export function DevToolsBar() {
|
||||
const offEscape = keymap.intercept(
|
||||
"key",
|
||||
({ event }) => {
|
||||
if (!panel() || event.name !== "escape") return
|
||||
if (!panel() || keymap.mode.current() !== "base") return
|
||||
if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return
|
||||
if (renderer.getSelection()?.getSelectedText()) {
|
||||
if ((config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) !== "select") return
|
||||
renderer.clearSelection()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
|
||||
@@ -759,6 +759,14 @@ export function Autocomplete(props: {
|
||||
hide()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prompt.clear",
|
||||
title: "Dismiss autocomplete",
|
||||
group: "Autocomplete",
|
||||
run() {
|
||||
hide(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prompt.autocomplete.select",
|
||||
title: "Select autocomplete item",
|
||||
|
||||
@@ -987,9 +987,19 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
Keymap.createLayer(() => {
|
||||
return {
|
||||
priority: 1,
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && store.mode === "shell",
|
||||
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }],
|
||||
commands: [
|
||||
{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
|
||||
{
|
||||
bind: "ctrl+c",
|
||||
title: "Exit shell mode",
|
||||
group: "Prompt",
|
||||
enabled: () => store.prompt.text === "",
|
||||
run: () => setStore("mode", "normal"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { projectName } from "../util/project"
|
||||
import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Keymap } from "../context/keymap"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
@@ -266,6 +267,11 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const dialog = useDialog()
|
||||
onCleanup(Keymap.use().mode.push("menu"))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "menu",
|
||||
commands: [{ bind: "escape,ctrl+c", title: "Close tab menu", group: "Tabs", run: props.onClose }],
|
||||
}))
|
||||
const actions = createMemo(() => {
|
||||
const sessionID = props.state.sessionID
|
||||
return [
|
||||
@@ -1050,7 +1056,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
if (closeHold() && heldLayout()) {
|
||||
const current = untrack(motion.value)
|
||||
const seeded = changed
|
||||
? seedSessionTabMotion(previous.split(":"), layout().tabs.map((tab) => tab.sessionID), current, next)
|
||||
? seedSessionTabMotion(
|
||||
previous.split(":"),
|
||||
layout().tabs.map((tab) => tab.sessionID),
|
||||
current,
|
||||
next,
|
||||
)
|
||||
: current
|
||||
if (!seeded) return motion.jump(next)
|
||||
motion.jump({ ...seeded, widths: next.widths })
|
||||
|
||||
@@ -423,6 +423,12 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
group: "VCS",
|
||||
run: close,
|
||||
},
|
||||
{
|
||||
id: "app.exit",
|
||||
title: "Close diff viewer",
|
||||
group: "VCS",
|
||||
run: close,
|
||||
},
|
||||
{
|
||||
id: "diff.down",
|
||||
title: "Move diff viewer down",
|
||||
|
||||
@@ -190,6 +190,9 @@ function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
|
||||
messageID: item.id,
|
||||
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
|
||||
delivery: item.delivery,
|
||||
...(item.payload.skills?.length
|
||||
? { skills: item.payload.skills.map((skill) => ({ id: skill.id, name: skill.name })) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +391,12 @@ function skillCommit(messageID: string, name: string, skillID = messageID): Stre
|
||||
}
|
||||
}
|
||||
|
||||
function skillCommits(messageID: string, skills: FooterQueuedPrompt["skills"] = []) {
|
||||
return Array.from(new Map(skills.map((skill) => [skill.id, skill])).values(), (skill) =>
|
||||
skillCommit(messageID, skill.name, skill.id),
|
||||
)
|
||||
}
|
||||
|
||||
function compactionCommit(messageID: string): StreamCommit {
|
||||
return {
|
||||
kind: "system",
|
||||
@@ -667,7 +676,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (!render) return
|
||||
if (reuseVisibleWait && waiting) return
|
||||
write([
|
||||
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
|
||||
...skillCommits(message.id, message.skills),
|
||||
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
|
||||
])
|
||||
return
|
||||
@@ -956,18 +965,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
syncPending()
|
||||
const visible = state.messageIDs.has(event.data.inboxID)
|
||||
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
|
||||
if (!waiting && pending && !visible) {
|
||||
write([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
},
|
||||
])
|
||||
}
|
||||
write([], { phase: "running", status: "waiting for assistant" })
|
||||
const commits = pending && !visible ? skillCommits(event.data.inboxID, pending.skills) : []
|
||||
if (!waiting && pending && !visible)
|
||||
commits.push({
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
})
|
||||
write(commits, { phase: "running", status: "waiting for assistant" })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.inbox.delivery.changed") {
|
||||
@@ -979,6 +986,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (state.messageIDs.has(event.data.inboxID)) return
|
||||
state.messageIDs.add(event.data.inboxID)
|
||||
write([
|
||||
...skillCommits(event.data.inboxID, pending.skills),
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
|
||||
@@ -93,6 +93,7 @@ export type FooterQueuedPrompt = {
|
||||
messageID: string
|
||||
prompt: RunPrompt
|
||||
delivery: RunDelivery
|
||||
skills?: ReadonlyArray<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
export type QueuedPromptAction = "steer" | "cancel"
|
||||
|
||||
@@ -99,6 +99,7 @@ export function Composer(props: ComposerProps) {
|
||||
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
||||
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
||||
{ bind: "escape", title: "Close composer", group: "Composer", run: close },
|
||||
{ bind: "ctrl+c", title: "Close composer", group: "Composer", run: close },
|
||||
],
|
||||
}))
|
||||
|
||||
|
||||
@@ -126,10 +126,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
},
|
||||
]
|
||||
},
|
||||
onClose: () => {
|
||||
const parentID = session()?.parentID
|
||||
if (parentID) navigate({ type: "session", sessionID: parentID })
|
||||
},
|
||||
})
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
@@ -540,6 +540,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
run() {
|
||||
const text = textarea?.plainText ?? ""
|
||||
if (!text) {
|
||||
if (textual()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -242,6 +242,11 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
if (sidebar() === "auto" && wide()) return true
|
||||
return false
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 10,
|
||||
enabled: () => sidebarOpen() && !wide() && !disabled(),
|
||||
commands: [{ bind: "escape,ctrl+c", title: "Close sidebar", group: "Session", run: () => setSidebarOpen(false) }],
|
||||
}))
|
||||
const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4)
|
||||
const models = createMemo(() => data.location.model.list(location()) ?? [])
|
||||
|
||||
@@ -1294,7 +1299,14 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
sessionID={route.sessionID}
|
||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
|
||||
onClose={() => setComposer("open", false)}
|
||||
onClose={() => {
|
||||
const parent = session()?.parentID
|
||||
if (parent) {
|
||||
navigate({ type: "session", sessionID: parent })
|
||||
return
|
||||
}
|
||||
setComposer("open", false)
|
||||
}}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
||||
|
||||
@@ -299,7 +299,11 @@ function RejectPrompt(props: {
|
||||
id: "app.exit",
|
||||
title: "Cancel permission rejection",
|
||||
group: "Permission",
|
||||
run() {
|
||||
run(_input, event) {
|
||||
if (event?.ctrl && event.name === "c" && input.plainText) {
|
||||
input.setText("")
|
||||
return
|
||||
}
|
||||
props.onCancel()
|
||||
},
|
||||
},
|
||||
@@ -436,6 +440,13 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const id = () => props.id ?? "session.permission"
|
||||
const group = () => props.group ?? "Permission"
|
||||
const dismiss = () => {
|
||||
if (store.expanded) {
|
||||
setStore("expanded", false)
|
||||
return
|
||||
}
|
||||
if (props.escapeKey) props.onSelect(props.escapeKey)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "base",
|
||||
@@ -447,7 +458,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
title: "Reject permission",
|
||||
group: group(),
|
||||
bind: false as const,
|
||||
run: () => props.onSelect(props.escapeKey!),
|
||||
run: dismiss,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -490,9 +501,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
group: group(),
|
||||
run: () => props.onSelect(store.selected),
|
||||
},
|
||||
...(props.escapeKey
|
||||
? [{ bind: "escape", title: "Reject permission", group: group(), run: () => props.onSelect(props.escapeKey!) }]
|
||||
: []),
|
||||
...(props.escapeKey ? [{ bind: "escape", title: "Reject permission", group: group(), run: dismiss }] : []),
|
||||
],
|
||||
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
|
||||
}))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
||||
import { InputRenderable, MouseButton, Renderable, RGBA } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useToast } from "./toast"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
@@ -112,7 +112,7 @@ function init() {
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
|
||||
enabled: store.stack.length > 0,
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
@@ -121,6 +121,7 @@ function init() {
|
||||
run: () => {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
return
|
||||
}
|
||||
const current = store.stack.at(-1)
|
||||
current?.onClose?.()
|
||||
@@ -135,6 +136,13 @@ function init() {
|
||||
run: () => {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
return
|
||||
}
|
||||
const editor = renderer.currentFocusedEditor
|
||||
if (editor?.plainText) {
|
||||
if (editor instanceof InputRenderable) editor.value = ""
|
||||
else editor.setText("")
|
||||
return
|
||||
}
|
||||
const current = store.stack.at(-1)
|
||||
current?.onClose?.()
|
||||
@@ -225,7 +233,9 @@ export function DialogProvider(props: ParentProps) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined}
|
||||
onMouseUp={
|
||||
copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined
|
||||
}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
|
||||
|
||||
@@ -636,3 +636,48 @@ test("configured app bindings execute settings and permission commands", async (
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("ctrl+c dismisses autocomplete and shell mode before exiting", async () => {
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, 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 },
|
||||
args: {},
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await ready.promise
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
await setup.mockInput.typeText("/theme")
|
||||
await setup.waitForFrame((frame) => frame.includes("Switch theme"))
|
||||
|
||||
setup.mockInput.pressKey("c", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => !frame.includes("Switch theme"))
|
||||
expect(setup.renderer.isDestroyed).toBe(false)
|
||||
|
||||
await setup.mockInput.typeText("!")
|
||||
await setup.waitForFrame((frame) => frame.includes("Shell"))
|
||||
setup.mockInput.pressKey("c", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => !frame.includes("Shell"))
|
||||
expect(setup.renderer.isDestroyed).toBe(false)
|
||||
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -187,6 +187,17 @@ test("configured composer bindings work with a focused textarea", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("ctrl+c closes the active composer", async () => {
|
||||
const composer = await renderComposer("shell", {})
|
||||
|
||||
try {
|
||||
composer.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await composer.app.waitFor(() => composer.closed() === 1)
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function session(id: string, title: string, parentID?: string) {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -163,6 +163,48 @@ test("budgets option content for constrained and full-width large dialogs", () =
|
||||
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69)
|
||||
})
|
||||
|
||||
test("ctrl+c clears a dialog filter before closing the dialog", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
|
||||
|
||||
try {
|
||||
await select.app.mockInput.typeText("alpha")
|
||||
await select.app.waitFor(() => select.app.renderer.currentFocusedEditor?.plainText === "alpha")
|
||||
|
||||
select.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await select.app.waitFor(() => select.app.renderer.currentFocusedEditor?.plainText === "")
|
||||
expect(select.app.captureCharFrame()).toContain("Mutable options")
|
||||
|
||||
select.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await select.app.waitForFrame((frame) => !frame.includes("Mutable options"))
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ctrl+c clears a dialog text selection before closing the dialog", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
|
||||
|
||||
try {
|
||||
const frame = select.app.captureCharFrame().split("\n")
|
||||
const row = frame.findIndex((line) => line.includes("Alpha"))
|
||||
const column = frame[row]!.indexOf("Alpha") + 1
|
||||
await select.app.mockMouse.click(column, row)
|
||||
await select.app.mockMouse.click(column, row)
|
||||
expect(select.app.renderer.getSelection()?.getSelectedText()).toBe("Alpha")
|
||||
|
||||
select.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await select.app.waitFor(() => !select.app.renderer.getSelection())
|
||||
expect(select.app.captureCharFrame()).toContain("Mutable options")
|
||||
|
||||
select.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await select.app.waitForFrame((frame) => !frame.includes("Mutable options"))
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renders the complete truncated footer within the option row", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const title = "Project"
|
||||
|
||||
@@ -49,6 +49,18 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("ctrl+c closes the diff viewer without exiting the application", async () => {
|
||||
const viewer = await renderDiffViewer([])
|
||||
|
||||
try {
|
||||
viewer.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await viewer.app.waitFor(() => viewer.current().type !== "plugin")
|
||||
expect(viewer.current()).toEqual(startRoute)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows an error instead of an empty diff when loading fails", async () => {
|
||||
const viewer = await renderDiffViewer([], { fail: true })
|
||||
try {
|
||||
|
||||
@@ -606,6 +606,25 @@ test("text fields retain default paste behavior", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("ctrl+c clears a text field before cancelling its form", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [{ key: "notes", type: "string" }])
|
||||
|
||||
try {
|
||||
await prompt.app.mockInput.typeText("draft answer")
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "draft answer")
|
||||
|
||||
prompt.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "")
|
||||
expect(prompt.cancellations).toEqual([])
|
||||
|
||||
prompt.app.mockInput.pressKey("c", { ctrl: true })
|
||||
await prompt.app.waitFor(() => prompt.cancellations.length === 1)
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("pasting on a choice without custom answers does not open an editor", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
|
||||
@@ -108,7 +108,9 @@ test("the tab context menu keeps preview tabs open without offering promotion fo
|
||||
await app.waitForFrame((frame) => frame.includes("Rename"))
|
||||
expect(app.captureCharFrame()).not.toContain("Keep open")
|
||||
|
||||
await app.mockMouse.click(5, 0)
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
await app.waitForFrame((frame) => !frame.includes("Rename"))
|
||||
|
||||
await app.mockMouse.click(40, 0, MouseButton.RIGHT)
|
||||
await app.waitForFrame((frame) => frame.includes("Keep open"))
|
||||
const frame = app.captureCharFrame().split("\n")
|
||||
|
||||
@@ -667,7 +667,13 @@ describe("V2 mini transport", () => {
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
payload: { text: "follow up" },
|
||||
payload: {
|
||||
text: "follow up",
|
||||
skills: [
|
||||
{ id: "effect", name: "Effect", text: "Use Effect services" },
|
||||
{ id: "effect", name: "Effect" },
|
||||
],
|
||||
},
|
||||
delivery: "queue",
|
||||
},
|
||||
{
|
||||
@@ -706,9 +712,10 @@ describe("V2 mini transport", () => {
|
||||
})
|
||||
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
|
||||
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
||||
)
|
||||
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toEqual([
|
||||
expect.objectContaining({ kind: "system", partID: "skill:effect", text: '→ Skill "Effect"' }),
|
||||
expect.objectContaining({ kind: "user", text: "follow up" }),
|
||||
])
|
||||
expect(pending()).toEqual([["msg_cancelled", "queue"]])
|
||||
events.push({
|
||||
id: "evt_queued",
|
||||
@@ -739,7 +746,7 @@ describe("V2 mini transport", () => {
|
||||
data: { sessionID: "ses_1", inboxID: "msg_queued" },
|
||||
})
|
||||
while (pending()?.length !== 0) await Bun.sleep(0)
|
||||
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
|
||||
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(2)
|
||||
const prompt = spyOn(client.session, "prompt").mockImplementation(
|
||||
(request) => ok(promptAdmission(request)) as never,
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface EntryPoint {
|
||||
export interface Interface {
|
||||
readonly add: (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
@@ -124,14 +124,16 @@ const layer = Layer.effect(
|
||||
}
|
||||
return pkg
|
||||
})
|
||||
const reify = (input: { dir: string; add?: string[] }) =>
|
||||
const refreshed = new Set<string>()
|
||||
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* flock.acquire(`npm-install:${input.dir}`)
|
||||
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
||||
const add = input.add ?? []
|
||||
const npmOptions = yield* NpmConfig.load(input.dir)
|
||||
const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
|
||||
const arborist = new Arborist({
|
||||
...npmOptions,
|
||||
...options,
|
||||
path: input.dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
@@ -141,8 +143,9 @@ const layer = Layer.effect(
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
arborist.reify({
|
||||
...npmOptions,
|
||||
...options,
|
||||
add,
|
||||
update: input.update,
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
}),
|
||||
@@ -159,19 +162,33 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
|
||||
const add = Effect.fn("Npm.add")(function* (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
|
||||
) {
|
||||
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
|
||||
const parsedName = (() => {
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return npa(pkg).name ?? undefined
|
||||
return npa(pkg)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
const parsedName = parsed?.name ?? undefined
|
||||
const dir = yield* directory(pkg)
|
||||
const name = yield* installedName(pkg, dir, parsedName)
|
||||
const cached = yield* afs.existsSafe(path.join(dir, "node_modules", name))
|
||||
const refresh = options?.refresh && isMutable(parsed) && !refreshed.has(pkg)
|
||||
|
||||
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
|
||||
if (refresh) {
|
||||
refreshed.add(pkg)
|
||||
if (cached)
|
||||
yield* reify({ dir, add: [pkg], update: true }).pipe(
|
||||
Effect.catchCause(() => Effect.logWarning("failed to refresh cached package; using installed version")),
|
||||
)
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
|
||||
}
|
||||
|
||||
@@ -283,3 +300,10 @@ export async function resolve(...args: Parameters<Interface["resolve"]>) {
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
return runPromise((svc) => svc.which(...args))
|
||||
}
|
||||
|
||||
function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) {
|
||||
if (!parsed) return false
|
||||
if (["tag", "range"].includes(parsed.type)) return true
|
||||
if (parsed.type !== "git") return false
|
||||
return !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(parsed.gitCommittish ?? "")
|
||||
}
|
||||
|
||||
@@ -16200,6 +16200,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
@@ -16200,6 +16200,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
@@ -93,8 +93,9 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
|
||||
Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure
|
||||
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
|
||||
|
||||
Changes under watched config directories reload automatically. Restart OpenCode after changing an installed package
|
||||
version or an unwatched dependency.
|
||||
Changes under watched config directories reload automatically. On server startup, OpenCode refreshes unpinned package and
|
||||
Git plugins once, then uses that result for the lifetime of the server. Exact npm versions and full Git commit hashes stay
|
||||
pinned. Changes to unwatched local dependencies may still require restarting OpenCode.
|
||||
|
||||
```sh
|
||||
touch .opencode/plugins/concise.ts
|
||||
|
||||
Reference in New Issue
Block a user