Compare commits

...
Author SHA1 Message Date
Kit Langton 37a686cb6c feat(tui): investigate errors in a new session 2026-08-25 23:56:04 -04:00
opencode-agent[bot] d01ac2069a chore: update nix node_modules hashes 2026-08-26 03:32:00 +00:00
Aiden Cline a78d3c5438 fix(ai): recover Anthropic request_too_large as overflow (#45144) 2026-08-25 22:30:14 -05:00
David Hill 474c3588c1 fix(tui): use unicode ellipses in interface text (#45143) 2026-08-25 22:26:15 -05:00
Kit Langton 9361117504 feat(core): add directory projects (#45107) 2026-08-25 23:22:23 -04:00
Aiden Cline 5add6a8e19 fix(ai): preserve provider-defined responses item ids (#45094) 2026-08-25 22:17:16 -05:00
Aiden Cline f3c390b89e feat(ai): add native DeepInfra provider (#45108) 2026-08-25 22:15:52 -05:00
opencode-agent[bot]andBrendonovich 185c3e5136 fix(app): show project logos throughout settings (#45134)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 02:43:53 +00:00
Kit Langton cb863b8ea5 fix(tui): dismiss the active interaction with ctrl-c (#45111) 2026-08-25 22:38:50 -04:00
Kit Langton 695c043e6b feat(core): refresh unpinned plugins on startup (#45118) 2026-08-25 22:32:05 -04:00
opencode-agent[bot] d53456da3b chore: update nix node_modules hashes 2026-08-26 02:16:23 +00:00
opencode-agent[bot]andBrendonovich 64e930628d fix(ci): compare affected packages against actual PR base (#45130)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-26 10:13:53 +08:00
Kit Langton 3c73ce1dc7 fix(core): materialize mentioned skills on prompts (#44840) 2026-08-25 22:04:09 -04:00
Kit Langton 4b71ae6a0d feat(core): support git plugin packages (#45110) 2026-08-25 22:02:49 -04:00
opencode-agent[bot]andBrendonovich e211b6f30e test: run only affected unit suites (#45034)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 09:51:44 +08:00
Kit Langton ab4621e437 fix(sdk): keep packaged Effect runtime coherent (#45122) 2026-08-26 01:51:36 +00:00
Kit Langton 97ba700fac fix(tui): preserve prompt metadata visibility across sessions (#45116) 2026-08-25 21:37:58 -04:00
Luke Parker 03fb5c6c67 fix(app): prevent clipped virtual timeline rows (#45115) 2026-08-26 01:33:32 +00:00
Luke Parker 5a54eb4afc fix(app): stream running shell tool output (#45106) 2026-08-26 00:58:15 +00:00
106 changed files with 1989 additions and 360 deletions
+19 -4
View File
@@ -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
@@ -71,6 +71,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -110,9 +111,16 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
GITHUB_ACTIONS=false bun turbo test
exit 0
fi
GITHUB_ACTIONS=false bun turbo test --affected
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
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
if: runner.os == 'Linux'
@@ -122,8 +130,15 @@ jobs:
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
timeout-minutes: 15
working-directory: packages/sdk
run: bun run verify:package
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
bun turbo verify:package --filter=@opencode-ai/sdk
exit 0
fi
bun turbo verify:package --affected --filter=@opencode-ai/sdk
env:
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
if: always()
+1 -7
View File
@@ -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",
@@ -972,6 +971,7 @@
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@npmcli/arborist": "catalog:",
"@npmcli/config": "10.8.1",
"@opentelemetry/api": "1.9.0",
@@ -1177,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=="],
@@ -5909,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
View File
@@ -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-t7k0Nk9Z7FMkAKt2UReI1jD1KoPB+LhQQx1x1p5Htlc=",
"aarch64-linux": "sha256-tU7C2zYKiX+RpwBBBLjX7vrqQNLPvDc5Uz9EbgGJvSA=",
"aarch64-darwin": "sha256-A3o+2zNuFHV5FZx/azdXBjcKdS5o41b2e2Q1Qq8C1m0=",
"x86_64-darwin": "sha256-NXgvteaxJQqBBZIHnQbivghHTJnL2IMZiFPNssGp2Os="
}
}
+17 -41
View File
@@ -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) => {
+4 -2
View File
@@ -40,15 +40,16 @@ const patterns = [
/model_context_window_exceeded/i,
/too many tokens/i,
/token limit exceeded/i,
/request_too_large/i,
]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const payloadPatterns = [/request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
(patterns.some((pattern) => pattern.test(message)) || /^4(?:00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
@@ -106,6 +107,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
clientScoped &&
(codes.includes("context_length_exceeded") ||
codes.includes("model_context_window_exceeded") ||
codes.includes("request_too_large") ||
isContextOverflow(text))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
+61
View File
@@ -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)
+1
View File
@@ -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"
+22
View File
@@ -211,6 +211,28 @@ describe("RequestExecutor", () => {
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
it.effect("classifies Anthropic request_too_large as context overflow", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "context-overflow",
http: { response: { status: 413 } },
})
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
]),
),
),
)
it.effect("does not classify ordinary invalid requests as context overflow", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
+9 -4
View File
@@ -18,13 +18,19 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true)
})
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
test("classifies Anthropic request_too_large as recoverable overflow", () => {
expect(
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
expect(isContextOverflow("413 status code (no body)")).toBe(true)
})
test("classifies generic request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
@@ -33,7 +39,6 @@ describe("provider error classification", () => {
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => {
+19
View File
@@ -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"}',
},
])
}),
)
@@ -92,6 +92,15 @@ export function createTimelineVirtualizer(input: Input) {
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
// Do not replace this with TanStack's default measurer: without a ResizeObserver entry,
// it returns the cached height instead of reading the element (TanStack/virtual#1183).
// Restored sessions, deferred tools, and rewrapped content can then keep stale heights;
// our fixed-height, overflow-clipped rows will hide their content. Keep observer entries
// on the cheap precomputed path, but make explicit measurements read the real height.
measureElement: (element, entry) => {
const box = entry?.borderBoxSize[0]
return box ? Math.round(box.blockSize) : element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
@@ -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)}
/>
)
}
@@ -6,6 +6,7 @@ import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
export function SessionUIProvider(
@@ -17,6 +18,7 @@ export function SessionUIProvider(
const navigate = useNavigate()
const params = useParams()
const data = useData()
const serverSDK = useServerSDK()
const tabs = useTabs()
const directory = () => props.directory
const href = (sessionID: string) => sessionHref(props.server, sessionID)
@@ -53,6 +55,7 @@ export function SessionUIProvider(
data={sessionUIData()}
directory={directory()}
sessionID={params.id}
shellOutput={(input) => serverSDK.api.shell.output(input)}
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
@@ -113,6 +113,32 @@ test("reactive count updates preserve measured row sizes", () => {
})
})
test("explicit measurement refreshes a cached row size with a custom measurer", () => {
const root = document.createElement("div")
const element = document.createElement("div")
element.dataset.index = "0"
Object.defineProperty(element, "offsetHeight", { value: 120 })
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 1,
estimateSize: () => 60,
initialRect: { width: 400, height: 200 },
getScrollElement: () => root,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
measureElement: (node) => node.offsetHeight,
})
virtualizer.getTotalSize()
virtualizer.resizeItem(0, 60)
virtualizer._willUpdate()
virtualizer.measureElement(element)
expect(virtualizer.itemSizeCache.get(0)).toBe(120)
expect(virtualizer.getTotalSize()).toBe(120)
})
test("initial rect projects rows before a scroll element connects", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
+1 -1
View File
@@ -181,7 +181,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("add", {
description: "Install a plugin and add it to the global configuration",
params: {
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
},
}),
Spec.make("remove", {
@@ -13,10 +13,8 @@ import { Config } from "../../../config"
export default Runtime.handler(
Commands.commands.plugin.commands.add,
Effect.fn("cli.plugin.add")(function* (input) {
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
return yield* Effect.fail(
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
)
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
@@ -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 }
@@ -974,7 +974,7 @@ export type WorktreeResolved = {
type: "worktree.resolved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { projectID: string; directory: string; previous: string }
data: { projectID: string; directory: string; previous: string; adopted?: Array<string> }
}
export type CommandUpdated = {
@@ -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"
+16 -1
View File
@@ -617,7 +617,22 @@ export function createData(config: CreateDataInput) {
}
case "worktree.resolved": {
for (const [sessionID, info] of Object.entries(store.session.info)) {
const adopted = Worktree.adopt({ projectID: info.projectID, directory: info.location.directory }, event.data)
const explicit = event.data.adopted?.includes(info.projectID)
const directory = explicit ? store.project.info[info.projectID]?.canonical : info.location.directory
if (!directory) {
if (info.location.workspaceID) continue
result.session.invalidate(sessionID)
void result.session.sync(sessionID)
continue
}
const adopted = Worktree.adopt(
{
projectID: info.projectID,
directory,
workspaceID: info.location.workspaceID,
},
event.data,
)
if (!adopted) continue
setStore("session", "info", sessionID, "projectID", adopted.projectID)
setStore("session", "info", sessionID, "subpath", adopted.subpath)
+100
View File
@@ -154,6 +154,106 @@ test("updates authoritative cached project metadata from live events", async ()
}
})
test("adopts cached directory-project sessions when their repository is resolved", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const refreshed: SessionInfo = {
...session(0),
id: "ses_uncached",
projectID: "repository",
location: { directory: "/unknown-alias" },
subpath: "app",
}
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/api/session/ses_uncached")) throw new Error(`Unexpected request: ${request.url}`)
return Response.json({ data: refreshed })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/repo",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
try {
const sessions: SessionInfo[] = [
{ ...session(0), id: "ses_root", projectID: "directory-root", location: { directory: "/repo" } },
{ ...session(0), id: "ses_nested", projectID: "directory-nested", location: { directory: "/repo/app" } },
{
...session(0),
id: "ses_alias",
projectID: "directory-nested",
location: { directory: "/repo/alias/../app" },
},
{ ...session(0), id: "ses_symlink", projectID: "directory-nested", location: { directory: "/shortcut" } },
{ ...refreshed, projectID: "directory-uncached" },
{ ...session(0), id: "ses_global", projectID: "global", location: { directory: "/repo/legacy" } },
{ ...session(0), id: "ses_escaped", projectID: "global", location: { directory: "/repo/../other" } },
{ ...session(0), id: "ses_other", projectID: "other-repository", location: { directory: "/repo/vendor" } },
{ ...session(0), id: "ses_sibling", projectID: "global", location: { directory: "/repo-other" } },
{
...session(0),
id: "ses_remote",
projectID: "directory-root",
location: { directory: "/repo", workspaceID: "workspace-remote" },
},
]
sessions.forEach((item) => setup.data.session.remember(item))
for (const project of [
{ id: "directory-root", canonical: "/repo" },
{ id: "directory-nested", canonical: "/repo/app" },
]) {
const updated: OpenCodeEvent = {
id: `evt_${project.id}`,
created: 0,
type: "project.updated",
data: { ...project, time: { created: 0, updated: 0 }, sandboxes: [] },
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
}
const resolved: OpenCodeEvent = {
id: "evt_repository_resolved",
created: 1,
type: "worktree.resolved",
durable: { aggregateID: "repository", seq: 0, version: 1 },
data: {
projectID: "repository",
directory: "/repo",
previous: "global",
adopted: ["directory-root", "directory-nested", "directory-uncached"],
},
}
listeners.forEach((listener) => listener({ name: resolved.type, details: resolved }))
expect(setup.data.session.get("ses_root")?.projectID).toBe("repository")
expect(setup.data.session.get("ses_root")?.subpath).toBeUndefined()
expect(setup.data.session.get("ses_nested")).toMatchObject({ projectID: "repository", subpath: "app" })
expect(setup.data.session.get("ses_alias")).toMatchObject({ projectID: "repository", subpath: "app" })
expect(setup.data.session.get("ses_symlink")).toMatchObject({ projectID: "repository", subpath: "app" })
expect(setup.data.session.get("ses_global")).toMatchObject({ projectID: "repository", subpath: "legacy" })
expect(setup.data.session.get("ses_escaped")?.projectID).toBe("global")
expect(setup.data.session.get("ses_other")?.projectID).toBe("other-repository")
expect(setup.data.session.get("ses_sibling")?.projectID).toBe("global")
expect(setup.data.session.get("ses_remote")?.projectID).toBe("directory-root")
await wait(() => setup.data.session.get("ses_uncached")?.projectID === "repository")
expect(setup.data.session.get("ses_uncached")?.subpath).toBe("app")
} finally {
setup.dispose()
}
})
test("refreshes global credential events across every loaded location and workspace", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
describe("Worktree.adopt", () => {
const event = {
projectID: "repository",
directory: "/repo",
previous: "previous",
adopted: ["directory-root", "directory-nested"],
}
test("adopts explicitly superseded directory projects", () => {
expect(Worktree.adopt({ projectID: "directory-root", directory: "/repo" }, event)).toEqual({
projectID: "repository",
subpath: undefined,
})
expect(Worktree.adopt({ projectID: "directory-nested", directory: "/repo/packages/app" }, event)).toEqual({
projectID: "repository",
subpath: "packages/app",
})
})
test("preserves existing previous-project and global adoption", () => {
expect(Worktree.adopt({ projectID: "previous", directory: "/repo/packages/app" }, event)).toEqual({
projectID: "repository",
subpath: "packages/app",
})
expect(Worktree.adopt({ projectID: "global", directory: "/repo/packages/app" }, event)).toEqual({
projectID: "repository",
subpath: "packages/app",
})
})
test("leaves unrelated projects, sibling directories, and workspaces unchanged", () => {
expect(Worktree.adopt({ projectID: "other", directory: "/repo/vendor" }, event)).toBeUndefined()
expect(Worktree.adopt({ projectID: "global", directory: "/repo-other" }, event)).toBeUndefined()
expect(Worktree.adopt({ projectID: "repository", directory: "/repo" }, event)).toBeUndefined()
expect(
Worktree.adopt({ projectID: "directory-root", directory: "/repo", workspaceID: "remote" }, event),
).toBeUndefined()
})
test("normalizes Windows directory separators", () => {
expect(
Worktree.adopt(
{ projectID: "directory-nested", directory: "C:\\repo\\packages\\app" },
{ ...event, directory: "C:\\repo" },
),
).toEqual({ projectID: "repository", subpath: "packages/app" })
expect(
Worktree.adopt(
{ projectID: "directory-nested", directory: "c:/Repo/packages/App" },
{ ...event, directory: "C:\\repo" },
),
).toEqual({ projectID: "repository", subpath: "packages/App" })
})
test("normalizes aliases and rejects paths that leave the repository", () => {
expect(Worktree.adopt({ projectID: "directory-nested", directory: "/repo/alias/../packages/app" }, event)).toEqual({
projectID: "repository",
subpath: "packages/app",
})
expect(Worktree.adopt({ projectID: "global", directory: "/repo/../other" }, event)).toBeUndefined()
expect(Worktree.adopt({ projectID: "global", directory: "/app" }, { ...event, directory: "/" })).toEqual({
projectID: "repository",
subpath: "app",
})
})
test("decodes existing durable events without adopted project IDs", () => {
expect(
Schema.decodeUnknownSync(Worktree.Event.Resolved.data)({
projectID: "repository",
directory: "/repo",
previous: "global",
}),
).toEqual({ projectID: "repository", directory: "/repo", previous: "global" })
})
})
-1
View File
@@ -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",
+2 -1
View File
@@ -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),
+2
View File
@@ -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" ||
-2
View File
@@ -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)
},
})
+1 -1
View File
@@ -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 =
+54 -13
View File
@@ -2,7 +2,7 @@ export * as Project from "./project.js"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { and, asc, desc, eq } from "drizzle-orm"
import { and, asc, desc, eq, gte, isNull, lte } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
@@ -104,7 +104,12 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
yield* upsertProject(db, project).pipe(Effect.orDie)
if (previous && previous.canonical !== project.canonical) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, project.id)).get().pipe(Effect.orDie)
const row = yield* db
.select()
.from(ProjectTable)
.where(eq(ProjectTable.id, project.id))
.get()
.pipe(Effect.orDie)
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
}
if (!project.vcs) return project
@@ -135,9 +140,38 @@ const layer = Layer.effect(
.get()
.pipe(Effect.orDie)
if (stored) return
const directory = AbsolutePath.make(yield* fs.resolve(item.directory))
const markerless = yield* db
.select({ id: ProjectTable.id, directory: ProjectTable.worktree })
.from(ProjectTable)
.where(
and(
isNull(ProjectTable.vcs),
gte(ProjectTable.worktree, directory),
lte(ProjectTable.worktree, AbsolutePath.make(directory + "\uffff")),
),
)
.all()
.pipe(Effect.orDie)
const adopted = yield* Effect.filter(markerless, (candidate) =>
Effect.gen(function* () {
if (candidate.id === item.projectID) return false
if (!FSUtil.contains(directory, candidate.directory)) return false
const markers = yield* fs
.up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
.pipe(Effect.orElseSucceed(() => []))
if (!markers[0]) return false
return (yield* fs.resolve(path.dirname(markers[0]))) === directory
}),
)
yield* bus.publish(
Worktree.Event.Resolved,
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
{
projectID: item.projectID,
directory: item.directory,
previous: project.previous ?? ID.global,
...(adopted.length ? { adopted: adopted.map((candidate) => candidate.id) } : {}),
},
{
commit: () =>
db
@@ -256,12 +290,7 @@ const layer = Layer.effect(
return node ? ID.make(node) : undefined
})
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
)
if (!dotHg) return undefined
const hgDiscover = Effect.fnUntraced(function* (dotHg: AbsolutePath) {
const worktree = AbsolutePath.make(path.dirname(dotHg))
const store = AbsolutePath.make(dotHg)
const previous = yield* cached(store)
@@ -275,7 +304,15 @@ const layer = Layer.effect(
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.repo.discover(input)
const directory = AbsolutePath.make(yield* fs.resolve(input))
const marker = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
)
const repo =
marker && path.basename(marker) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(marker)))
: undefined
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
@@ -295,10 +332,14 @@ const layer = Layer.effect(
})
}
const hg = yield* hgDiscover(input)
const hg = marker && path.basename(marker) === ".hg" ? yield* hgDiscover(AbsolutePath.make(marker)) : undefined
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
const directory = AbsolutePath.make(path.parse(input).root)
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
return yield* persist({
id: ID.make(Hash.fast(`directory:${directory}`)),
directory,
canonical: directory,
vcs: undefined,
})
})
return Service.of({ list, update, resolve })
+1
View File
@@ -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")],
+22 -12
View File
@@ -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 })
})
+5 -1
View File
@@ -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}.`
+31 -11
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector.js"
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { Database } from "../database/database.js"
@@ -23,6 +23,7 @@ import { Worktree } from "@opencode-ai/schema/worktree"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
import { ProjectTable } from "../project/sql.js"
type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
@@ -478,18 +479,34 @@ const layer = Layer.effectDiscard(
// are untouched: the session did not move, its directory got identified.
yield* bus.project(Worktree.Event.Resolved, (event) =>
Effect.gen(function* () {
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
if (stale.length === 0) return
const candidates = [
...new Set(
[event.data.previous, Project.ID.global, ...(event.data.adopted ?? [])].filter(
(id) => id !== event.data.projectID,
),
),
]
if (candidates.length === 0) return
const rows = yield* db
.select({ id: SessionTable.id, directory: SessionTable.directory })
.select({
id: SessionTable.id,
directory: SessionTable.directory,
projectID: SessionTable.project_id,
canonical: ProjectTable.worktree,
})
.from(SessionTable)
.innerJoin(ProjectTable, eq(SessionTable.project_id, ProjectTable.id))
.where(
and(
inArray(SessionTable.project_id, stale),
// Lexicographic range narrows the scan to prefix neighbors without
// LIKE escaping; FSUtil.contains below decides containment exactly.
gte(SessionTable.directory, event.data.directory),
lte(SessionTable.directory, AbsolutePath.make(event.data.directory + "\uffff")),
inArray(SessionTable.project_id, candidates),
isNull(SessionTable.workspace_id),
or(
event.data.adopted?.length ? inArray(SessionTable.project_id, event.data.adopted) : undefined,
and(
gte(SessionTable.directory, event.data.directory),
lte(SessionTable.directory, AbsolutePath.make(event.data.directory + "\uffff")),
),
),
),
)
.all()
@@ -497,12 +514,15 @@ const layer = Layer.effectDiscard(
yield* Effect.forEach(
rows,
(row) => {
if (!FSUtil.contains(event.data.directory, row.directory)) return Effect.void
const directory = event.data.adopted?.includes(row.projectID)
? row.canonical
: AbsolutePath.make(path.resolve(row.directory))
if (!FSUtil.contains(event.data.directory, directory)) return Effect.void
return db
.update(SessionTable)
.set({
project_id: event.data.projectID,
path: RelativePath.make(path.relative(event.data.directory, row.directory).replaceAll("\\", "/")),
path: RelativePath.make(path.relative(event.data.directory, directory).replaceAll("\\", "/")),
// Self-assignment suppresses the column's $onUpdate: adoption is not activity.
time_updated: sql`${SessionTable.time_updated}`,
})
@@ -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 ?? []),
]
+1
View File
@@ -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,
+20
View File
@@ -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())
}),
-1
View File
@@ -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>"]),
+2 -17
View File
@@ -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) => ({
+2 -2
View File
@@ -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",
+16
View File
@@ -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")
+147
View File
@@ -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"
@@ -21,6 +22,39 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
const npmLayer = (cache: string) =>
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
async function createGitFixture(directory: string) {
const repository = path.join(directory, "repository")
await fs.mkdir(path.join(repository, "dependency"), { recursive: true })
await writePackage(repository, {
name: "fixture-git-plugin",
exports: "./index.js",
dependencies: { "fixture-dependency": "file:./dependency" },
})
await writePackage(path.join(repository, "dependency"), { name: "fixture-dependency", exports: "./index.js" })
await Bun.write(path.join(repository, "index.js"), "export default { root: true }\n")
await Bun.write(path.join(repository, "dependency", "index.js"), "export const dependency = true\n")
const subdirectory = path.join(repository, "packages", "subdirectory-plugin")
await fs.mkdir(path.join(subdirectory, "dependency"), { recursive: true })
await writePackage(subdirectory, {
name: "fixture-subdirectory-plugin",
exports: "./index.js",
dependencies: { "fixture-subdirectory-dependency": "file:./dependency" },
})
await writePackage(path.join(subdirectory, "dependency"), {
name: "fixture-subdirectory-dependency",
exports: "./index.js",
})
await Bun.write(path.join(subdirectory, "index.js"), "export default { subdirectory: true }\n")
await Bun.write(path.join(subdirectory, "dependency", "index.js"), "export const dependency = true\n")
await Bun.$`git init -q -b fixture-branch ${repository}`
await Bun.$`git -C ${repository} add .`
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm fixture`
const commit = await Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim())
return { repository, commit }
}
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -46,6 +80,33 @@ describe("Npm.isRegistryPackage", () => {
})
})
describe("Npm.isInstallablePackage", () => {
test("accepts registry and npm-compatible Git specs", async () => {
expect(await Npm.isInstallablePackage("plugin@^1.2.0")).toBe(true)
expect(await Npm.isInstallablePackage("github:acme/plugin#main")).toBe(true)
expect(await Npm.isInstallablePackage("git+ssh://git@github.com/acme/plugin.git#main")).toBe(true)
expect(await Npm.isInstallablePackage("git@github.com:acme/plugin.git")).toBe(true)
expect(
await Npm.isInstallablePackage(
"git+https://github.com/acme/plugins.git#0123456789abcdef0123456789abcdef01234567::path:packages/plugin",
),
).toBe(true)
expect(await Npm.isInstallablePackage("./plugin")).toBe(false)
expect(await Npm.isInstallablePackage("https://example.com/plugin.tgz")).toBe(false)
expect(await Npm.isInstallablePackage("alias@npm:plugin@1.0.0")).toBe(false)
})
})
describe("Npm.cacheKey", () => {
test("preserves registry keys and hashes Git specs", async () => {
expect(await Npm.cacheKey("@opencode/acme@1.0.0")).toBe(Npm.sanitize("@opencode/acme@1.0.0"))
const spec = "git+ssh://git@github.com/acme/plugin.git#main"
expect(await Npm.cacheKey(spec)).toMatch(/^git-[a-f0-9]{64}$/)
expect(await Npm.cacheKey(spec)).toBe(await Npm.cacheKey(spec))
expect(await Npm.cacheKey(`${spec}-other`)).not.toBe(await Npm.cacheKey(spec))
})
})
describe("Npm.add", () => {
test("resolves cached scoped package specs without reifying", async () => {
await using tmp = await tmpdir()
@@ -116,6 +177,92 @@ describe("Npm.add", () => {
expect(entries.tui.entrypoint).toEndWith("/tui.js")
expect(entries.fallback.entrypoint).toEndWith("/index.js")
})
test("installs and resolves named and unnamed Git packages with dependencies", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const specs = [
`git+file://${fixture.repository}#${fixture.commit}`,
`fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`,
]
for (const spec of specs) {
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
added: yield* npm.add(spec),
cached: yield* npm.add(spec),
resolved: yield* npm.resolve(spec),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(entries.added.entrypoint).toEndWith("/index.js")
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
).toBeTruthy()
expect(entries.added.directory).toContain(path.join("packages", await Npm.cacheKey(spec), "node_modules"))
}
})
test("installs a Git package from an npm ::path: subdirectory", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const spec = `git+file://${fixture.repository}#${fixture.commit}::path:packages/subdirectory-plugin`
const entry = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(entry.entrypoint).toEndWith("/index.js")
expect(
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" },
+75 -3
View File
@@ -145,24 +145,54 @@ async function rootCommit(dir: string) {
}
describe("Project.resolve", () => {
it.live("returns global for non-git directory", () =>
it.live("creates distinct deterministic projects for exact markerless directories", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const project = yield* Project.Service
const nested = path.join(tmp.path, "notes", "drafts")
yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
const result = yield* project.resolve(abs(tmp.path))
const repeated = yield* project.resolve(abs(`${tmp.path}${path.sep}.`))
const child = yield* project.resolve(abs(nested))
expect(result.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
expect(result.id).not.toBe(Project.ID.global)
expect(repeated.id).toBe(result.id)
expect(child.id).not.toBe(result.id)
expect(result.directory).toBe(yield* real(tmp.path))
expect(child.directory).toBe(yield* real(nested))
expect(result.canonical).toBe(result.directory)
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),
)
it.live("repository markers override markerless directory projects", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const nested = path.join(tmp.path, "packages", "app")
yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
const project = yield* Project.Service
const root = yield* project.resolve(abs(tmp.path))
const child = yield* project.resolve(abs(nested))
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
const repository = yield* project.resolve(abs(nested))
expect(root.id).not.toBe(child.id)
expect(repository.id).not.toBe(root.id)
expect(repository.id).not.toBe(child.id)
expect(repository.directory).toBe(yield* real(tmp.path))
expect(repository.vcs?.type).toBe("git")
}),
)
it.live("does not publish project updates for first or repeated resolutions", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
@@ -411,6 +441,48 @@ describe("Project.resolve", () => {
}),
)
it.live("prefers the nearest mercurial marker over an outer git repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const nested = path.join(tmp.path, "nested")
yield* Effect.promise(async () => {
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(nested, ".hg"), { recursive: true })
await fs.mkdir(path.join(nested, "app"))
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(nested, "app")))
expect(result.vcs?.type).toBe("hg")
expect(result.directory).toBe(yield* real(nested))
}),
)
it.live("prefers the nearest git marker over an outer mercurial repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const nested = path.join(tmp.path, "nested")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".hg"))
await fs.mkdir(path.join(nested, "app"), { recursive: true })
await initRepo(nested, { commit: true })
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(nested, "app")))
expect(result.vcs?.type).toBe("git")
expect(result.directory).toBe(yield* real(nested))
}),
)
it.live("returns global id for unreadable mercurial metadata", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
+1
View File
@@ -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: "" },
])
+117 -6
View File
@@ -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"
@@ -95,14 +96,37 @@ describe("Session.create", () => {
const { db } = yield* Database.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const nested = Location.Ref.make({ directory: AbsolutePath.make(path.join(directory, "packages", "app")) })
const aliased = Location.Ref.make({
directory: AbsolutePath.make([path.join(directory, "alias"), "..", "packages", "app"].join(path.sep)),
})
const created = yield* session.create({ location: ref, title: "Before git" })
const child = yield* session.create({ location: nested, title: "Nested before git" })
const originalUpdated = created.time.updated
const alias = yield* session.create({ location: aliased, title: "Aliased before git" })
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
yield* session.prompt({ sessionID: created.id, text: "Preserved history", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const pending = yield* session.prompt({ sessionID: created.id, text: "Preserved inbox", resume: false })
yield* store.claim(created.id)
const before = yield* db
.select()
.from(SessionTable)
.where(eq(SessionTable.id, created.id))
.get()
.pipe(Effect.orDie)
if (!before) return yield* Effect.die(new Error("created session not found"))
yield* Effect.promise(async () => {
await $`git init -q`.cwd(directory)
await $`git config user.email test@example.com`.cwd(directory)
await $`git config user.name Test`.cwd(directory)
})
const unborn = yield* projects.resolve(ref.directory)
const unbornRoot = yield* session.get(created.id)
const unbornNested = yield* session.get(child.id)
const unbornAlias = yield* session.get(alias.id)
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "README.md"), "test\n")
await $`git add README.md`.cwd(directory)
await $`git commit -qm initial`.cwd(directory)
@@ -113,10 +137,25 @@ describe("Session.create", () => {
const repeat = yield* projects.resolve(ref.directory)
const adopted = yield* session.get(created.id)
const nestedAdopted = yield* session.get(child.id)
const aliasAdopted = yield* session.get(alias.id)
const page = yield* session.list({ project: project.id })
const log = Array.from(yield* Stream.runCollect(logEvents(session, created.id)))
expect(created.projectID).toBe(Project.ID.global)
expect(created.projectID).not.toBe(Project.ID.global)
expect(child.projectID).not.toBe(created.projectID)
expect(alias.projectID).toBe(child.projectID)
expect(unborn.id).toBe(Project.ID.global)
expect(unbornRoot).toMatchObject({ projectID: Project.ID.global, subpath: undefined })
expect(unbornNested).toMatchObject({ projectID: Project.ID.global, subpath: "packages/app" })
expect(unbornAlias).toMatchObject({ projectID: Project.ID.global, subpath: "packages/app" })
expect(
yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, Project.ID.global))
.get()
.pipe(Effect.orDie),
).toMatchObject({ data: { adopted: expect.arrayContaining([created.projectID, child.projectID]) } })
expect(project.id).toBe(Project.ID.make(Hash.fast("git-remote:github.com/owner/adopted")))
expect(repeat.id).toBe(project.id)
expect(page.data.map((item) => item.id)).toEqual(expect.arrayContaining([created.id, child.id]))
@@ -124,16 +163,37 @@ describe("Session.create", () => {
projectID: project.id,
location: ref,
subpath: undefined,
time: { updated: originalUpdated },
time: { updated: DateTime.makeUnsafe(before.time_updated) },
})
expect(nestedAdopted).toMatchObject({
projectID: project.id,
location: nested,
subpath: RelativePath.make("packages/app"),
})
expect(aliasAdopted).toMatchObject({
projectID: project.id,
location: aliased,
subpath: RelativePath.make("packages/app"),
})
// Adoption is a project-domain fact; the session log records nothing new.
expect(log.map((event) => event.type)).toEqual(["session.created"])
expect(yield* session.messages({ sessionID: created.id })).toEqual([])
expect(log.map((event) => event.type)).toEqual([
"session.created",
"session.inbox.enqueued",
"session.inbox.delivered",
"session.inbox.enqueued",
])
expect(yield* session.messages({ sessionID: created.id })).toMatchObject([
{ id: expect.any(String), type: "user", text: "Preserved history" },
])
expect(yield* SessionInbox.find(db, pending.id)).toMatchObject({ payload: { text: "Preserved inbox" } })
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, created.id)).get().pipe(Effect.orDie),
).toMatchObject({
time_created: before.time_created,
time_updated: before.time_updated,
time_suspended: before.time_suspended,
resume_attempts: before.resume_attempts,
})
// Repeated resolution announces the directory's identity exactly once.
const announced = yield* db
.select({ type: EventTable.type })
@@ -146,6 +206,46 @@ describe("Session.create", () => {
),
)
liveIt.live("does not adopt nested repositories or sessions in another workspace", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* Session.Service
const projects = yield* Project.Service
const outer = path.join(directory, "outer")
const nested = path.join(outer, "nested")
const child = AbsolutePath.make(path.join(nested, "app"))
yield* Effect.promise(() => fs.mkdir(child, { recursive: true }))
const local = yield* session.create({ location: Location.Ref.make({ directory: AbsolutePath.make(outer) }) })
const inside = yield* session.create({ location: Location.Ref.make({ directory: child }) })
const remote = yield* session.create({
location: Location.Ref.make({
directory: AbsolutePath.make(outer),
workspaceID: Workspace.ID.make("wrk_remote"),
}),
})
yield* Effect.promise(async () => {
for (const root of [nested, outer]) {
await $`git init -q`.cwd(root)
await $`git config user.email test@example.com`.cwd(root)
await $`git config user.name Test`.cwd(root)
await $`git commit --allow-empty -qm initial`.cwd(root)
}
})
const parent = yield* projects.resolve(AbsolutePath.make(outer))
expect((yield* session.get(local.id)).projectID).toBe(parent.id)
expect((yield* session.get(inside.id)).projectID).toBe(inside.projectID)
expect((yield* session.get(remote.id)).projectID).toBe(remote.projectID)
const repository = yield* projects.resolve(AbsolutePath.make(nested))
expect((yield* session.get(inside.id)).projectID).toBe(repository.id)
expect((yield* session.get(remote.id)).projectID).toBe(remote.projectID)
}),
),
)
it.effect("persists a missing title until one is generated or supplied", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -1178,6 +1278,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 +1314,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" } },
])
+3 -1
View File
@@ -132,6 +132,7 @@ const it = testEffect(
LayerNode.group([
Database.node,
Bus.node,
Project.node,
SessionProjector.node,
SessionStore.node,
Agent.node,
@@ -200,6 +201,7 @@ const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
const bus = yield* Bus.Service
const agents = yield* Agent.Service
const projects = yield* Project.Service
const instructionBuiltIns = yield* InstructionBuiltIns.Service
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("build"), (agent) => {
@@ -210,7 +212,7 @@ const setup = Effect.gen(function* () {
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
project_id: (yield* projects.resolve(AbsolutePath.make("/project"))).id,
slug: "generate-test",
directory: "/project",
title: "Generate test",
@@ -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(
[
+57 -17
View File
@@ -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" }),
])
}),
)
+2
View File
@@ -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>",
+1
View File
@@ -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),
}),
)
+3
View File
@@ -16200,6 +16200,9 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+1
View File
@@ -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" })
+31 -12
View File
@@ -57,27 +57,46 @@ const Resolved = durable({
projectID: Project.ID,
directory: AbsolutePath,
previous: Project.ID,
adopted: optional(Schema.Array(Project.ID)),
},
})
export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) }
export function adopt(
session: { readonly projectID: string; readonly directory: string },
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
session: { readonly projectID: string; readonly directory: string; readonly workspaceID?: string },
event: {
readonly projectID: string
readonly directory: string
readonly previous: string
readonly adopted?: ReadonlyArray<string>
},
) {
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
if (session.workspaceID) return
if (
session.projectID !== event.previous &&
session.projectID !== Project.ID.global &&
!event.adopted?.includes(session.projectID)
)
return
if (session.projectID === event.projectID) return
const inside =
session.directory === event.directory ||
session.directory.startsWith(event.directory + "/") ||
session.directory.startsWith(event.directory + "\\")
if (!inside) return
const normalize = (value: string) =>
value
.replaceAll("\\", "/")
.split("/")
.reduce((result, segment) => {
if (!segment || segment === ".") return result
if (segment === "..") return result.slice(0, result.lastIndexOf("/"))
return `${result}/${segment}`
}, "")
const directory = normalize(session.directory)
const root = normalize(event.directory)
const windows = /^\/[a-z]:/i.test(root)
const key = windows ? directory.toLowerCase() : directory
const parent = windows ? root.toLowerCase() : root
if (key !== parent && !key.startsWith(parent + "/")) return
return {
projectID: event.projectID,
subpath:
session.directory === event.directory
? undefined
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
subpath: key === parent ? undefined : directory.slice(root.length + 1),
}
}
@@ -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)(
+4
View File
@@ -173,6 +173,10 @@ for (const module of modules) {
const sdk = archives.get("@opencode-ai/sdk")
if (!sdk) throw new Error("Packed SDK archive was not created")
await $`npm install --ignore-scripts --no-audit --no-fund --package-lock=false ${sdk} wrangler@4.110.0`.cwd(consumer)
const runtimes = (await $`npm ls effect --all --parseable`.cwd(consumer).text()).trim().split("\n")
if (runtimes.length !== 1) {
throw new Error(`Packed SDK consumer resolved multiple Effect runtimes:\n${runtimes.join("\n")}`)
}
await $`bun imports.mjs`.cwd(consumer)
await $`bun --conditions=workerd imports.mjs`.cwd(consumer)
await $`node_modules/.bin/wrangler deploy --dry-run --config wrangler.jsonc --outdir dist`.cwd(consumer)
+9 -1
View File
@@ -1,4 +1,10 @@
import type { FileDiffInfo, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type {
FileDiffInfo,
SessionInfo,
SessionStatus,
ShellOutputInput,
ShellOutputOutput,
} from "@opencode-ai/client/promise"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -40,6 +46,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
data: Data
directory: string
sessionID?: string
shellOutput?: (input: ShellOutputInput) => Promise<ShellOutputOutput>
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
}) => {
@@ -55,6 +62,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
shellOutput: props.shellOutput,
}
},
})
@@ -6,6 +6,7 @@ import {
createSignal,
For,
Match,
onCleanup,
onMount,
Show,
Switch,
@@ -1294,15 +1295,41 @@ ToolRegistry.register({
name: "shell",
render(props) {
const i18n = useI18n()
const data = useData()
const streaming = () => props.status === "streaming"
const pending = () => streaming() || props.status === "running" || props.metadata.status === "running"
const sawStreaming = streaming()
const [streamed, setStreamed] = createSignal("")
createEffect(() => {
const id = props.metadata.shellID
const shellOutput = data.shellOutput
if (typeof id !== "string" || !pending() || !shellOutput) return
const directory = data.directory
let cursor = 0
let loading = false
let disposed = false
const load = async () => {
if (loading) return
loading = true
const response = await shellOutput({ id, location: { directory }, cursor }).catch(() => undefined)
if (disposed) return
if (response?.data.output) setStreamed((output) => output + response.data.output)
if (response) cursor = response.data.cursor
loading = false
}
void load()
const interval = setInterval(() => void load(), 1_000)
onCleanup(() => {
disposed = true
clearInterval(interval)
})
})
const command = () => {
if (typeof props.input.command === "string") return props.input.command
if (typeof props.metadata.command === "string") return props.metadata.command
return ""
}
const output = createMemo(() => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n"))
const output = createMemo(() => stripAnsi((pending() && streamed()) || props.output || "").replace(/\r\n?/g, "\n"))
return (
<BasicTool
{...props}
+2 -2
View File
@@ -614,7 +614,7 @@ function App(props: { pair?: DialogPairCredentials }) {
return
}
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "" : title}`)
return
}
@@ -971,7 +971,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const restart = client.restart
if (!restart) return
dialog.clear()
toast.show({ variant: "info", message: "Restarting service...", duration: 30000 })
toast.show({ variant: "info", message: "Restarting service", duration: 30000 })
// restart resolves once the replacement service is healthy; the
// event stream reattaches through the reconnect loop.
await restart()
+10 -2
View File
@@ -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()
@@ -352,7 +360,7 @@ export function DevToolsBar() {
<PanelBox>
<PanelTitle>Tools</PanelTitle>
<Action onClick={() => void dump()} disabled={dumping()} hoverBackground>
{dumping() ? "Writing debug snapshot..." : "Write debug snapshot"}
{dumping() ? "Writing debug snapshot" : "Write debug snapshot"}
</Action>
<Show when={dumpPath()}>
{(file) => (
@@ -3,13 +3,22 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useLocation } from "../context/location"
import { useRoute } from "../context/route"
import { getScrollAcceleration } from "../util/scroll"
import { useTheme } from "../context/theme"
import { emptyPrompt } from "../prompt/history"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
const clipboard = useClipboard()
const data = useData()
const dialog = useDialog()
const location = useLocation()
const route = useRoute()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
@@ -49,11 +58,25 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
.catch(toast.error)
}
const investigate = () => {
const target = location.ref ?? data.location.default()
route.navigate({
type: "home",
location: target,
prompt: {
...emptyPrompt(),
text: `Investigate this OpenCode failure in ${target.directory}.\n\n${props.title}\n${props.error}\n\nInspect the relevant configuration and logs, identify the root cause, and suggest a fix.`,
},
})
dialog.clear()
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack },
{ bind: "c", title: "Copy details", group: "Dialog", run: copy },
{ bind: "i", title: "Investigate error", group: "Dialog", run: investigate },
],
}))
@@ -102,12 +125,20 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
</span>
<span style={{ fg: theme.text.subdued }}>{scrollable() ? " scroll" : ""}</span>
</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() ? "✓ copied" : "c"}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
<box flexDirection="row" gap={3}>
<text onMouseUp={investigate}>
<span style={{ fg: theme.text.default }}>
<b>i</b>
</span>
<span style={{ fg: theme.text.subdued }}> investigate</span>
</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() ? "✓ copied" : "c"}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
</box>
</box>
</box>
)
@@ -352,7 +352,7 @@ function CommandStarting(props: {
if (!handedOff) closed = true
})
return <CommandView title={props.method.label} output="" message="Starting command..." />
return <CommandView title={props.method.label} output="" message="Starting command" />
}
function CommandPending(props: {
@@ -412,7 +412,7 @@ function CommandPending(props: {
})
})
return <CommandView title={props.title} output={output()} message="Waiting for command to finish..." />
return <CommandView title={props.title} output={output()} message="Waiting for command to finish" />
}
function CommandView(props: { title: string; output: string; message: string }) {
@@ -541,7 +541,7 @@ function OAuthStarting(props: {
})
})
return <OAuthView title={props.method.label} message="Starting authorization..." />
return <OAuthView title={props.method.label} message="Starting authorization" />
}
function OAuthAuto(props: {
@@ -629,7 +629,7 @@ function OAuthAuto(props: {
title={props.title}
url={props.attempt.url}
instructions={props.attempt.instructions}
message="Waiting for authorization..."
message="Waiting for authorization"
copy
open
/>
@@ -953,7 +953,7 @@ async function externalAnswer(
if (choice === true) return true
const result = await new Promise<boolean | typeof CANCELLED>((resolve) => {
dialog.replace(
() => <OAuthView title={formLabel(field) || title} message="Opening link..." />,
() => <OAuthView title={formLabel(field) || title} message="Opening link" />,
() => resolve(CANCELLED),
)
void open(field.url).then(
@@ -237,7 +237,7 @@ function buildIssueURL(message: string, stack: string, version: string) {
// clipped trace is obvious. searchParams.set handles encoding without throwing,
// so measuring url.toString() is both correct and safe on any input.
const MAX_URL_LENGTH = 6000
const marker = "\n... (truncated)"
const marker = "\n (truncated)"
const head = `The OpenCode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```")
@@ -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",
+25 -6
View File
@@ -102,6 +102,7 @@ export type PromptRef = {
}
const DRAFT_RETENTION_MIN_CHARS = 20
const revealedPromptMetadata = new WeakSet<object>()
function randomIndex(count: number) {
if (count <= 0) return 0
@@ -986,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"),
},
],
}
})
@@ -1608,12 +1619,20 @@ export function Prompt(props: PromptProps) {
return promptDisplay().agentColor ?? theme.border.default
})
const agentLabel = createMemo(() => (store.mode === "shell" ? "Shell" : promptDisplay().agentLabel))
const agentMetaAlpha = createFadeIn(() => !!agentLabel(), animationsEnabled)
const modelMetaAlpha = createFadeIn(() => !!promptDisplay().agentLabel && store.mode === "normal", animationsEnabled)
const animateMetadata = !revealedPromptMetadata.has(local)
const metadataAnimationsEnabled = () => animationsEnabled() && animateMetadata
const agentMetaAlpha = createFadeIn(() => !!agentLabel(), metadataAnimationsEnabled)
const modelMetaAlpha = createFadeIn(
() => !!promptDisplay().agentLabel && store.mode === "normal",
metadataAnimationsEnabled,
)
const variantMetaAlpha = createFadeIn(
() => !!promptDisplay().agentLabel && store.mode === "normal" && !!promptDisplay().variant,
animationsEnabled,
metadataAnimationsEnabled,
)
createEffect(() => {
if (agentLabel()) revealedPromptMetadata.add(local)
})
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
const footerInput = () => ({ sessionID: props.sessionID, mode: store.mode })
@@ -1622,10 +1641,10 @@ export function Prompt(props: PromptProps) {
const value = (() => {
if (store.mode === "shell") {
if (!shell().length) return undefined
return `Run a command... "${shell()[store.placeholder % shell().length]}"`
return `Run a command "${shell()[store.placeholder % shell().length]}"`
}
if (!list().length) return undefined
return `Ask anything... "${list()[store.placeholder % list().length]}"`
return `Ask anything "${list()[store.placeholder % list().length]}"`
})()
if (!value) return undefined
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
+1 -1
View File
@@ -28,7 +28,7 @@ export function Reconnecting(props: { managed?: boolean }) {
paddingRight={2}
gap={1}
>
<Spinner color={theme.text.default}>{props.managed ? "Restarting service..." : "Connection lost..."}</Spinner>
<Spinner color={theme.text.default}>{props.managed ? "Restarting service" : "Connection lost"}</Spinner>
<text fg={theme.text.subdued}>
{props.managed
? "Your session will resume automatically."
+12 -1
View File
@@ -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 })
@@ -5,7 +5,7 @@ import { Spinner } from "./spinner"
export function StartupLoading(props: { ready: () => boolean }) {
const theme = useTheme("elevated")
const [show, setShow] = createSignal(false)
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
const text = createMemo(() => (props.ready() ? "Finishing startup" : "Loading plugins"))
let wait: NodeJS.Timeout | undefined
let hold: NodeJS.Timeout | undefined
let stamp = 0
@@ -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",
+21 -13
View File
@@ -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",
+1
View File
@@ -93,6 +93,7 @@ export type FooterQueuedPrompt = {
messageID: string
prompt: RunPrompt
delivery: RunDelivery
skills?: ReadonlyArray<{ id: string; name: string }>
}
export type QueuedPromptAction = "steer" | "cancel"
+10 -7
View File
@@ -48,17 +48,20 @@ export function Home() {
const bind = (r: PromptRef | undefined) => {
setRef(r)
promptRef.set(r)
if (once || !r) return
if (route.prompt) {
r.set(route.prompt)
once = true
return
}
if (!args.prompt) return
if (once || !r || route.prompt || !args.prompt) return
r.set({ text: args.prompt, files: [], agents: [], pasted: [] })
once = true
}
createEffect(() => {
const composer = ref()
const text = route.prompt?.text
if (!composer || text === undefined) return
const prompt = route.prompt
if (!prompt) return
untrack(() => composer.set(prompt))
})
// Wait for the model store to be ready before auto-submitting --prompt.
createEffect(() => {
const r = ref()
@@ -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)
})
@@ -75,7 +75,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov
when={!pending()}
fallback={
<box paddingLeft={2} paddingRight={2} paddingBottom={1}>
<Spinner>Forking session...</Spinner>
<Spinner>Forking session</Spinner>
</box>
}
>
+4
View File
@@ -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
}
+25 -13
View File
@@ -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>
@@ -3122,9 +3134,9 @@ function Shell(props: ToolProps) {
when={command()}
fallback={
isRunning() || props.part.state.status === "streaming" ? (
<Spinner color={color()}>Writing command...</Spinner>
<Spinner color={color()}>Writing command</Spinner>
) : (
<text fg={theme.text.subdued}>Writing command...</text>
<text fg={theme.text.subdued}>Writing command</text>
)
}
>
@@ -3171,7 +3183,7 @@ function Write(props: ToolProps) {
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={stringValue(props.input.path)} part={props.part}>
<InlineTool icon="←" pending="Preparing write" complete={stringValue(props.input.path)} part={props.part}>
Write {pathFormatter.format(stringValue(props.input.path))}
</InlineTool>
</Match>
@@ -3182,7 +3194,7 @@ function Write(props: ToolProps) {
function Glob(props: ToolProps) {
const pathFormatter = usePathFormatter()
return (
<InlineTool icon="✱" pending="Finding files..." complete={stringValue(props.input.pattern)} part={props.part}>
<InlineTool icon="✱" pending="Finding files" complete={stringValue(props.input.pattern)} part={props.part}>
Glob "{stringValue(props.input.pattern)}"{" "}
<Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
<Show when={finiteNumber(props.metadata.count)}>
@@ -3206,7 +3218,7 @@ function Read(props: ToolProps) {
<>
<InlineTool
icon="→"
pending="Reading file..."
pending="Reading file"
complete={stringValue(props.input.path)}
spinner={isRunning()}
part={props.part}
@@ -3229,7 +3241,7 @@ function Read(props: ToolProps) {
function Grep(props: ToolProps) {
const pathFormatter = usePathFormatter()
return (
<InlineTool icon="✱" pending="Searching content..." complete={stringValue(props.input.pattern)} part={props.part}>
<InlineTool icon="✱" pending="Searching content" complete={stringValue(props.input.pattern)} part={props.part}>
Grep "{stringValue(props.input.pattern)}"{" "}
<Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
<Show when={finiteNumber(props.metadata.matches)}>
@@ -3241,7 +3253,7 @@ function Grep(props: ToolProps) {
function WebFetch(props: ToolProps) {
return (
<InlineTool icon="%" pending="Fetching from the web..." complete={stringValue(props.input.url)} part={props.part}>
<InlineTool icon="%" pending="Fetching from the web" complete={stringValue(props.input.url)} part={props.part}>
WebFetch {stringValue(props.input.url)}
</InlineTool>
)
@@ -3249,7 +3261,7 @@ function WebFetch(props: ToolProps) {
function WebSearch(props: ToolProps) {
return (
<InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}>
<InlineTool icon="◈" pending="Searching web" complete={stringValue(props.input.query)} part={props.part}>
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"
</InlineTool>
)
@@ -3271,7 +3283,7 @@ function Subagent(props: ToolProps) {
icon={continuation() ? "↳" : isRunning() ? "│" : props.part.state.status === "completed" ? "✓" : "│"}
spinner={!continuation() && isRunning()}
complete={description()}
pending="Delegating..."
pending="Delegating"
part={props.part}
onClick={() => {
const id = sessionID()
@@ -3462,7 +3474,7 @@ function Edit(props: ToolProps) {
? { label: "← Edit", value: pathFormatter.format(stringValue(props.input.path)) }
: undefined
}
title={stringValue(props.input.path) ? undefined : "# Preparing edit..."}
title={stringValue(props.input.path) ? undefined : "# Preparing edit"}
part={props.part}
spinner={props.part.state.status === "streaming"}
/>
@@ -3617,7 +3629,7 @@ function Question(props: ToolProps) {
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
<InlineTool icon="→" pending="Asking questions" complete={count()} part={props.part}>
Asked {count()} question{count() !== 1 ? "s" : ""}
</InlineTool>
</Match>
@@ -3628,7 +3640,7 @@ function Question(props: ToolProps) {
function Skill(props: ToolProps) {
const name = createMemo(() => stringValue(props.metadata.name) ?? stringValue(props.input.id))
return (
<InlineTool icon="→" pending="Loading skill..." complete={name()} part={props.part}>
<InlineTool icon="→" pending="Loading skill" complete={name()} part={props.part}>
Skill "{name()}"
</InlineTool>
)
+14 -5
View File
@@ -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 -2
View File
@@ -100,11 +100,11 @@ export function DialogPrompt(props: DialogPromptProps) {
cursorStyle={config.cursor}
/>
<Show when={props.busy}>
<Spinner color={theme.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
<Spinner color={theme.text.subdued}>{props.busyText ?? "Working"}</Spinner>
</Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={theme.text.subdued}>processing...</text>}>
<Show when={!props.busy} fallback={<text fg={theme.text.subdued}>processing</text>}>
<Show when={shortcuts.get("dialog.prompt.submit")}>
<text fg={theme.text.default}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.text.subdued }}>submit</span>
+13 -3
View File
@@ -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}>
+117
View File
@@ -297,6 +297,78 @@ test("session startup prompt is submitted exactly once", async () => {
}
})
test("error investigations repeatedly seed editable home drafts without creating sessions", 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 cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
let created = 0
const calls = createFetch((url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/mcp")
return json({
location,
data: [
{ name: "alpha", status: { status: "failed", error: "Alpha connection refused" } },
{ name: "beta", status: { status: "failed", error: "Beta initialization failed" } },
],
})
if (url.pathname === "/api/session" && request.method === "POST") created++
return 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, keybinds: { "mcp.list": "f6" } }),
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"))
setup.mockInput.pressKey("F6")
await setup.waitForFrame((frame) => frame.includes("MCP servers") && frame.includes("alpha"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Alpha connection refused") && frame.includes("i investigate"))
setup.mockInput.pressKey("i")
await setup.waitForFrame((frame) => frame.includes("Alpha connection refused") && !frame.includes("i investigate"))
setup.mockInput.pressKey("F6")
await setup.waitForFrame((frame) => frame.includes("MCP servers"))
setup.mockInput.pressArrow("down")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Beta initialization failed") && frame.includes("i investigate"))
setup.mockInput.pressKey("i")
const draft = await setup.waitForFrame(
(frame) => frame.includes("Beta initialization failed") && !frame.includes("i investigate"),
)
expect(draft).not.toContain("Alpha connection refused")
expect(created).toBe(0)
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitForFrame((frame) => !frame.includes("Beta initialization failed"))
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("shows jump to latest after scrolling one line above the final message", async () => {
const setup = await createTestRenderer({ width: 80, height: 20, useThread: false, kittyKeyboard: true })
setup.renderer.start()
@@ -636,3 +708,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,
+60 -18
View File
@@ -7,7 +7,8 @@ import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider } from "../../../src/context/location"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
@@ -32,19 +33,51 @@ test.each(["enter", "space"])("starts OAuth with %s for an MCP server requiring
}
})
async function renderMcp() {
test("opens an investigation draft for a failed MCP server at its originating location", async () => {
const location = { directory: "/projects/example", workspaceID: "workspace_example" }
const fixture = await renderMcp({ failed: true, location })
try {
await fixture.app.waitForFrame((frame) => frame.includes("Failed !"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitForFrame((frame) => frame.includes("i investigate"))
fixture.app.mockInput.pressKey("i")
await fixture.app.waitForFrame((frame) => !frame.includes("MCP server: linear"))
expect(fixture.route.data.type).toBe("home")
if (fixture.route.data.type !== "home") throw new Error("Expected investigation to open a new session")
expect(fixture.route.data.location).toEqual(location)
expect(fixture.route.data.prompt?.text).toContain("MCP server: linear")
expect(fixture.route.data.prompt?.text).toContain("MCP error -32000: Connection closed")
expect(fixture.route.data.prompt?.text).toContain(location.directory)
} finally {
fixture.app.renderer.destroy()
}
})
async function renderMcp(options?: { failed?: boolean; location?: { directory: string; workspaceID?: string } }) {
const events = createEventStream()
let oauth = 0
let connect = 0
let route!: ReturnType<typeof useRoute>
const calls = createFetch((url, request) => {
const location = {
directory: process.cwd(),
...(options?.location ?? { directory: process.cwd() }),
project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() },
}
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/mcp")
return json({
location,
data: [{ name: "linear", status: { status: "needs_auth" }, integrationID: "mcp_linear" }],
data: [
{
name: "linear",
status: options?.failed
? { status: "failed", error: "MCP error -32000: Connection closed" }
: { status: "needs_auth" },
integrationID: "mcp_linear",
},
],
})
if (url.pathname === "/api/integration")
return json({
@@ -84,10 +117,14 @@ async function renderMcp() {
function Probe() {
const data = useData()
const dialog = useDialog()
const location = useLocation()
route = useRoute()
onMount(() => {
void Promise.all([data.location.mcp.server.sync(), data.location.integration.sync()]).then(() =>
dialog.replace(() => <DialogMcp />),
)
if (options?.location) location.set(options.location)
void Promise.all([
data.location.mcp.server.sync(options?.location),
data.location.integration.sync(options?.location),
]).then(() => dialog.replace(() => <DialogMcp />))
})
return null
}
@@ -98,17 +135,19 @@ async function renderMcp() {
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
<Probe />
</DialogProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_existing" }}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
<Probe />
</DialogProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
@@ -119,6 +158,9 @@ async function renderMcp() {
app.renderer.start()
return {
app,
get route() {
return route
},
get oauth() {
return oauth
},
@@ -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"
@@ -59,7 +59,7 @@ describe("DiffViewerFileTree", () => {
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
))
expect(loading).not.toContain("Loading diff...")
expect(loading).not.toContain("Loading diff")
expect(loading).not.toContain("No files")
expect(failed).not.toContain("Failed to load diff")
expect(failed).not.toContain("No files")
@@ -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 {
+19
View File
@@ -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, [
@@ -73,7 +73,7 @@ function Fixture(props: { errorExpanded?: boolean }) {
function FailedPendingToolFixture() {
return (
<InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
<InlineToolRow icon="%" complete={false} pending="Preparing patch" failed={true} failure="Patch failed">
Patch
</InlineToolRow>
)
@@ -81,7 +81,7 @@ function FailedPendingToolFixture() {
function FailedCompleteToolFixture() {
return (
<InlineToolRow icon="→" complete={true} pending="Reading file..." failed={true} failure="Read failed">
<InlineToolRow icon="→" complete={true} pending="Reading file" failed={true} failure="Read failed">
Read src/index.ts
</InlineToolRow>
)
@@ -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,
)

Some files were not shown because too many files have changed in this diff Show More