mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-26 02:14:43 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 844125a92c | |||
| cec3dda4a4 |
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Find affected packages
|
||||
id: packages
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
@@ -71,7 +71,6 @@ 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
|
||||
@@ -111,16 +110,9 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
GITHUB_ACTIONS=false bun turbo test
|
||||
exit 0
|
||||
fi
|
||||
GITHUB_ACTIONS=false bun turbo test --affected
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
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'
|
||||
@@ -130,15 +122,8 @@ jobs:
|
||||
- name: Verify packed workerd SDK
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 15
|
||||
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 }}
|
||||
working-directory: packages/sdk
|
||||
run: bun run verify:package
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
"@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",
|
||||
@@ -971,7 +972,6 @@
|
||||
"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,6 +1177,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5907,6 +5909,10 @@
|
||||
|
||||
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-t7k0Nk9Z7FMkAKt2UReI1jD1KoPB+LhQQx1x1p5Htlc=",
|
||||
"aarch64-linux": "sha256-tU7C2zYKiX+RpwBBBLjX7vrqQNLPvDc5Uz9EbgGJvSA=",
|
||||
"aarch64-darwin": "sha256-A3o+2zNuFHV5FZx/azdXBjcKdS5o41b2e2Q1Qq8C1m0=",
|
||||
"x86_64-darwin": "sha256-NXgvteaxJQqBBZIHnQbivghHTJnL2IMZiFPNssGp2Os="
|
||||
"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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +372,9 @@ 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
|
||||
@@ -381,6 +384,10 @@ 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 }
|
||||
@@ -440,37 +447,53 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
|
||||
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
||||
})
|
||||
|
||||
// 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.
|
||||
// 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}$/
|
||||
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
|
||||
const metadata = providerMetadata?.[providerMetadataKey]
|
||||
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
|
||||
return ProviderShared.isRecord(metadata) &&
|
||||
typeof metadata.itemId === "string" &&
|
||||
ITEM_ID_PATTERN.test(metadata.itemId)
|
||||
? metadata.itemId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||
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 id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
return {
|
||||
type: "function_call",
|
||||
...(id === undefined ? {} : { id }),
|
||||
...(acceptsItemID(extension, "function-call", id) ? { id } : {}),
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
}
|
||||
}
|
||||
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const lowerReasoning = (
|
||||
part: ReasoningPart,
|
||||
providerMetadataKey: string,
|
||||
extension: Extension,
|
||||
): 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 === undefined ? {} : { id }),
|
||||
id,
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
}
|
||||
@@ -591,7 +614,8 @@ 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 id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const rawID = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined
|
||||
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)
|
||||
@@ -616,23 +640,23 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
|
||||
if (!reasoning) continue
|
||||
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
|
||||
const existing = reasoningItems[reasoning.id]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
if (typeof reasoning.encrypted_content === "string")
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
continue
|
||||
}
|
||||
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
|
||||
reasoningItems[reasoning.id] = reasoning
|
||||
input.push(reasoning)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
flushText()
|
||||
if (part.providerExecuted === true) continue
|
||||
input.push(lowerToolCall(part, providerMetadataKey))
|
||||
input.push(lowerToolCall(part, providerMetadataKey, extension))
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
@@ -644,7 +668,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
: Schema.is(HostedToolItem)(part.result.value)
|
||||
? part.result.value
|
||||
: extension.lowerHostedToolItem?.(part.result.value)
|
||||
if (id !== undefined && hosted?.id === id) {
|
||||
if (acceptsItemID(extension, "hosted-tool", id) && hosted?.id === id) {
|
||||
if (!hostedToolItems.has(id)) {
|
||||
input.push(hosted)
|
||||
hostedToolItems.add(id)
|
||||
|
||||
@@ -86,10 +86,28 @@ 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) => {
|
||||
|
||||
@@ -40,16 +40,15 @@ const patterns = [
|
||||
/model_context_window_exceeded/i,
|
||||
/too many tokens/i,
|
||||
/token limit exceeded/i,
|
||||
/request_too_large/i,
|
||||
]
|
||||
|
||||
const payloadPatterns = [/request entity too large/i, /payload too large/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 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)) || /^4(?:00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||
|
||||
@@ -107,7 +106,6 @@ 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" })
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("deepinfra")
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
id: "deepinfra-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.deepinfra.baseURL },
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const root = baseURL?.replace(/\/+$/, "")
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: {
|
||||
baseURL: root === undefined ? profiles.deepinfra.baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
|
||||
},
|
||||
auth: AuthOptions.bearer(input, "DEEPINFRA_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
|
||||
@@ -211,28 +211,6 @@ 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
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "deepinfra",
|
||||
"route": "deepinfra-chat",
|
||||
"transport": "http",
|
||||
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "text", "golden"],
|
||||
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-text",
|
||||
"recordedAt": "2026-08-26T00:34:03.019Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"!\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":25,\"total_tokens\":28,\"completion_tokens\":3,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":25,\"total_tokens\":28,\"completion_tokens\":3,\"estimated_cost\":3.46e-6,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "deepinfra",
|
||||
"route": "deepinfra-chat",
|
||||
"transport": "http",
|
||||
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "tool", "tool-call", "golden"],
|
||||
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-tool-call",
|
||||
"recordedAt": "2026-08-26T00:34:04.173Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_SMfBjXa8eCmHLyjfeyARxe3a\",\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\",\"name\":\"get_weather\"},\"type\":\"function\"}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":230,\"total_tokens\":244,\"completion_tokens\":14,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":230,\"total_tokens\":244,\"completion_tokens\":14,\"estimated_cost\":0.000027480000000000005,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,19 +18,13 @@ describe("provider error classification", () => {
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies Anthropic request_too_large as recoverable overflow", () => {
|
||||
expect(
|
||||
test("classifies request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
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 }),
|
||||
]
|
||||
|
||||
@@ -39,6 +33,7 @@ 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", () => {
|
||||
|
||||
@@ -28,7 +28,6 @@ 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()
|
||||
@@ -38,24 +37,6 @@ 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, Message } from "../../src/index.js"
|
||||
import { LLM } 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, fixedResponse } from "../lib/http.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
@@ -83,39 +83,6 @@ 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, DeepInfra, TogetherAI } from "../../src/providers/index.js"
|
||||
import { Cerebras, 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,9 +55,6 @@ 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")
|
||||
@@ -225,13 +222,6 @@ 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, DeepInfra, TogetherAI } from "../../src/providers/index.js"
|
||||
import { Cerebras, 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,18 +29,6 @@ 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(
|
||||
@@ -94,21 +82,6 @@ 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]) {
|
||||
@@ -149,12 +122,6 @@ 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,19 +195,15 @@ 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: "Long.",
|
||||
providerMetadata: { openresponses: { itemId: `history_${"a".repeat(64)}` } },
|
||||
text: "Dropped.",
|
||||
providerMetadata: { openresponses: { itemId: `m${"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" } } },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -222,23 +218,8 @@ describe("Open Responses-compatible route", () => {
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: `history_${"a".repeat(64)}`,
|
||||
role: "assistant",
|
||||
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." },
|
||||
],
|
||||
content: [{ type: "output_text", text: "Dropped." }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -3150,50 +3150,38 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves provider-issued item ids and removes malformed ids without dropping items", () =>
|
||||
it.effect("drops replayed item ids outside the server's grammar", () =>
|
||||
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: `message_${"a".repeat(64)}` } },
|
||||
providerMetadata: { openai: { itemId: `m${"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_" } },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -3202,44 +3190,18 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "history_1",
|
||||
role: "assistant",
|
||||
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." }],
|
||||
content: [
|
||||
{ type: "output_text", text: "Hello" },
|
||||
{ type: "output_text", text: "World" },
|
||||
],
|
||||
},
|
||||
{
|
||||
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"}',
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -133,7 +133,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
|
||||
return {
|
||||
adapter,
|
||||
project: createComposerProjectControls({ draftId: props.draftID, worktree: props.worktree }),
|
||||
project: createComposerProjectControls({ draftId: props.draftID }),
|
||||
model,
|
||||
ready: prompt.ready,
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { serverName, ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { workspaceSelectionDestination } from "@/workspaces/paths"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import type { PromptProjectControls } from "./selector"
|
||||
|
||||
export function createComposerProjectControls(props: { draftId: string; worktree: () => string }) {
|
||||
export function createComposerProjectControls(props: { draftId: string }) {
|
||||
const servers = useServers()
|
||||
const serverSDK = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
@@ -39,7 +38,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
||||
tabs.updateDraft(props.draftId, {
|
||||
server: ServerConnection.key(connection),
|
||||
directory: worktree,
|
||||
worktree: workspaceSelectionDestination(props.worktree(), location().directory),
|
||||
worktree: undefined,
|
||||
branch: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import {
|
||||
isWorkspaceDirectory,
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
sameDirectory,
|
||||
workspaceDefaultSelection,
|
||||
workspaceDirectories,
|
||||
workspaceSelectionDestination,
|
||||
} from "@/workspaces/paths"
|
||||
|
||||
export function resolveNewSessionWorktree(input: {
|
||||
@@ -60,7 +57,6 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const [state, setState] = createStore({ search: "" })
|
||||
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
|
||||
const currentProject = createMemo(() => {
|
||||
@@ -125,8 +121,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const remember = (worktree = value()) => {
|
||||
const project = currentProject()
|
||||
if (!project) return
|
||||
tabs.initializeDraftWorktrees(ServerConnection.key(serverSDK.server), sdk().directory, fallback())
|
||||
const local = workspaceSelectionDestination(worktree, project.worktree) === "main"
|
||||
const local = worktree === "main" || sameDirectory(worktree, project.worktree)
|
||||
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
|
||||
}
|
||||
|
||||
@@ -146,7 +141,6 @@ export function createNewSessionWorkspaceController(input: {
|
||||
set: (worktree: string) => {
|
||||
input.setSelectedBranch(undefined)
|
||||
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
|
||||
remember(worktree)
|
||||
},
|
||||
create: (branch: string) => {
|
||||
input.setSelectedBranch(branch)
|
||||
@@ -166,10 +160,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const loaded = branches.latest
|
||||
const list = loaded?.directory === projectRoot() ? loaded.data : []
|
||||
return [
|
||||
...new Set([
|
||||
...list,
|
||||
...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : []),
|
||||
]),
|
||||
...new Set([...list, ...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : [])]),
|
||||
].slice(0, 50)
|
||||
},
|
||||
searchBranches,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
|
||||
import { queuedPromptRows } from "./queue"
|
||||
|
||||
const queued = [
|
||||
{
|
||||
id: "msg_original",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
delivery: "queue",
|
||||
payload: { text: "original" },
|
||||
},
|
||||
{
|
||||
id: "msg_replacement",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 2,
|
||||
type: "user",
|
||||
delivery: "queue",
|
||||
payload: { text: "edited" },
|
||||
},
|
||||
] satisfies SessionInboxInfo[]
|
||||
|
||||
describe("queuedPromptRows", () => {
|
||||
test("keeps the edited prompt to one row while its replacement is admitted", () => {
|
||||
expect(queuedPromptRows(queued, { original: "msg_original", replacement: "msg_replacement" })).toEqual([
|
||||
{ id: "msg_replacement", text: "edited", attachments: false },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps the original visible until its replacement appears", () => {
|
||||
expect(queuedPromptRows([queued[0]], { original: "msg_original", replacement: "msg_replacement" })).toEqual([
|
||||
{ id: "msg_original", text: "original", attachments: false },
|
||||
])
|
||||
})
|
||||
|
||||
test("retains unrelated queue entries", () => {
|
||||
expect(queuedPromptRows(queued)).toEqual([
|
||||
{ id: "msg_original", text: "original", attachments: false },
|
||||
{ id: "msg_replacement", text: "edited", attachments: false },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps other prompts visible while a mutation replaces the edited prompt", () => {
|
||||
const other = { ...queued[0], id: "msg_other", payload: { text: "other" } }
|
||||
|
||||
expect(
|
||||
queuedPromptRows([queued[0], other, queued[1]], { original: "msg_original", replacement: "msg_replacement" }),
|
||||
).toEqual([
|
||||
{ id: "msg_other", text: "other", attachments: false },
|
||||
{ id: "msg_replacement", text: "edited", attachments: false },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
@@ -36,67 +34,39 @@ export function createSessionQueue(input: {
|
||||
const server = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash } }>({})
|
||||
const notify = () => showToast({ title: language.t("common.requestFailed") })
|
||||
const mutation = useMutation(() => ({
|
||||
mutationFn: async (
|
||||
change:
|
||||
| { type: "reorder"; inboxIDs: string[] }
|
||||
| {
|
||||
type: "edit"
|
||||
inboxIDs: string[]
|
||||
original: string
|
||||
replacement: string
|
||||
item: QueuedPrompt | undefined
|
||||
prompt: Prompt
|
||||
text: string
|
||||
delivery: ComposerDelivery
|
||||
},
|
||||
) => {
|
||||
if (change.type === "reorder") return rewrite(change.inboxIDs)
|
||||
const replacement = await editedPromptInput(
|
||||
input.sessionID,
|
||||
location().directory,
|
||||
change.item,
|
||||
change.prompt,
|
||||
change.text,
|
||||
)
|
||||
// Admit before cancelling so a failed replacement never discards the original.
|
||||
const admitted = await data.session.prompt({
|
||||
...replacement,
|
||||
id: change.replacement,
|
||||
delivery: change.delivery,
|
||||
...(change.delivery === "queue" ? { resume: false } : {}),
|
||||
})
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
|
||||
cancelEdit()
|
||||
if (change.delivery === "queue")
|
||||
await rewrite(change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)))
|
||||
},
|
||||
onError: notify,
|
||||
onSettled: () => data.session.pending.sync(input.sessionID).catch(() => undefined),
|
||||
}))
|
||||
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash }; busy: boolean }>({ busy: false })
|
||||
|
||||
const queued = createMemo(() =>
|
||||
data.session.pending
|
||||
.list(input.sessionID)
|
||||
.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"),
|
||||
)
|
||||
const rows = createMemo(() => {
|
||||
const replacement = mutation.isPending ? mutation.variables : undefined
|
||||
return queuedPromptRows(
|
||||
queued(),
|
||||
replacement?.type === "edit" && replacement.delivery === "queue" ? replacement : undefined,
|
||||
)
|
||||
})
|
||||
const rows = createMemo(() =>
|
||||
queued().map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments: (item.payload.files?.length ?? 0) > 0,
|
||||
})),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const editing = state.editing
|
||||
if (!editing || mutation.isPending || queued().some((item) => item.id === editing.id)) return
|
||||
if (!editing || state.busy || queued().some((item) => item.id === editing.id)) return
|
||||
setState("editing", undefined)
|
||||
})
|
||||
onCleanup(() => cancelEdit())
|
||||
|
||||
const notify = () => showToast({ title: language.t("common.requestFailed") })
|
||||
const run = (work: () => Promise<unknown>) => {
|
||||
setState("busy", true)
|
||||
return work()
|
||||
.catch(() => notify())
|
||||
.finally(async () => {
|
||||
await data.session.pending.sync(input.sessionID).catch(() => undefined)
|
||||
setState("busy", false)
|
||||
})
|
||||
}
|
||||
|
||||
const rewrite = async (inboxIDs: string[]) => {
|
||||
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
|
||||
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
|
||||
@@ -138,12 +108,12 @@ export function createSessionQueue(input: {
|
||||
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
|
||||
}
|
||||
const reorder = (inboxIDs: string[]) => {
|
||||
if (mutation.isPending) return Promise.resolve()
|
||||
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
|
||||
if (state.busy) return Promise.resolve()
|
||||
return run(() => rewrite(inboxIDs))
|
||||
}
|
||||
|
||||
const edit = (id: string) => {
|
||||
if (mutation.isPending) return false
|
||||
if (state.busy) return false
|
||||
if (state.editing?.id === id) return true
|
||||
const item = queued().find((entry) => entry.id === id)
|
||||
if (!item) return false
|
||||
@@ -177,22 +147,25 @@ export function createSessionQueue(input: {
|
||||
}
|
||||
const confirmEdit = (delivery: ComposerDelivery) => {
|
||||
const editing = state.editing
|
||||
if (!editing || mutation.isPending) return
|
||||
if (!editing || state.busy) return
|
||||
const prompt = clonePrompt(input.draft.current())
|
||||
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
if (!text.trim() && !prompt.some((part) => part.type === "image")) return cancelEdit()
|
||||
const item = queued().find((entry) => entry.id === editing.id)
|
||||
const pristine = item && text.trim() === queuedPromptText(item) && !prompt.some((part) => part.type === "image")
|
||||
if (pristine && delivery === "queue") return cancelEdit()
|
||||
mutation.mutate({
|
||||
type: "edit",
|
||||
inboxIDs: queued().map((entry) => entry.id),
|
||||
original: editing.id,
|
||||
replacement: SessionMessage.ID.create(),
|
||||
item,
|
||||
prompt,
|
||||
text,
|
||||
delivery,
|
||||
const inboxIDs = queued().map((entry) => entry.id)
|
||||
void run(async () => {
|
||||
const replacement = await editedPromptInput(input.sessionID, location().directory, item, prompt, text)
|
||||
// Admit before cancelling so a failed replacement never discards the original.
|
||||
const admitted = await data.session.prompt({
|
||||
...replacement,
|
||||
delivery,
|
||||
...(delivery === "queue" ? { resume: false } : {}),
|
||||
})
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: editing.id })
|
||||
cancelEdit()
|
||||
if (delivery === "queue") await rewrite(inboxIDs.map((id) => (id === editing.id ? admitted.id : id)))
|
||||
})
|
||||
}
|
||||
const editFirst = () => {
|
||||
@@ -214,7 +187,7 @@ export function createSessionQueue(input: {
|
||||
cancelEdit,
|
||||
editFirst,
|
||||
rows,
|
||||
busy: () => mutation.isPending,
|
||||
busy: () => state.busy,
|
||||
working: input.working,
|
||||
steer,
|
||||
remove,
|
||||
@@ -231,17 +204,6 @@ export type SessionQueueView = Pick<
|
||||
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
|
||||
>
|
||||
|
||||
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
|
||||
const replaced = replacement && items.some((item) => item.id === replacement.replacement)
|
||||
return items
|
||||
.filter((item) => !replaced || item.id !== replacement.original)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments: (item.payload.files?.length ?? 0) > 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export function queuedPromptText(item: QueuedPrompt) {
|
||||
const display = item.payload.metadata?.["displayText"]
|
||||
return typeof display === "string" && display.length > 0 ? display : item.payload.text
|
||||
|
||||
@@ -92,15 +92,6 @@ 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,8 +11,7 @@ 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 } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { createEditProjectModel } from "./project-model"
|
||||
import { ProjectSettingsExtensions } from "./project-extensions"
|
||||
import { SettingsServerDataScope } from "@/settings/server-scope"
|
||||
@@ -58,7 +57,11 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
|
||||
<Tabs.List>
|
||||
<div class="project-settings-nav">
|
||||
<Tabs.Trigger value="general">
|
||||
<ProjectIcon project={props.project} class="!size-4 shrink-0" />
|
||||
<ProjectAvatar
|
||||
fallback={projectName()}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
class="!size-4 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{projectName()}</span>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="scripts">
|
||||
@@ -111,14 +114,14 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<ProjectIcon
|
||||
project={props.project}
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
icon={{
|
||||
src={getProjectAvatarSource(props.project.id, {
|
||||
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,12 +1,13 @@
|
||||
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"
|
||||
@@ -17,7 +18,6 @@ export const SettingsProjects: Component = () => {
|
||||
const global = useGlobal()
|
||||
const [allServers, setAllServers] = createSignal(true)
|
||||
const selected = global.settings.server.selected
|
||||
const multiple = createMemo(() => global.servers.list().length > 1)
|
||||
const projects = createMemo(() => {
|
||||
const server = selected()
|
||||
if (!server) return []
|
||||
@@ -40,13 +40,14 @@ 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)] transition-all hover:bg-v2-background-bg-layer-01"
|
||||
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">
|
||||
<ProjectIcon project={props.project} class="shrink-0" />
|
||||
<ProjectAvatar fallback={name()} variant={color()} 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">
|
||||
@@ -73,16 +74,14 @@ export const SettingsProjects: Component = () => {
|
||||
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
|
||||
</div>
|
||||
<Show when={multiple()}>
|
||||
<InlineServerSelect
|
||||
all={{
|
||||
label: language.t("settings.projects.server.all"),
|
||||
selected: allServers,
|
||||
onSelect: () => setAllServers(true),
|
||||
}}
|
||||
onServerSelect={() => setAllServers(false)}
|
||||
/>
|
||||
</Show>
|
||||
<InlineServerSelect
|
||||
all={{
|
||||
label: language.t("settings.projects.server.all"),
|
||||
selected: allServers,
|
||||
onSelect: () => setAllServers(true),
|
||||
}}
|
||||
onServerSelect={() => setAllServers(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,14 +100,7 @@ export const SettingsProjects: Component = () => {
|
||||
>
|
||||
<Show when={selected()} keyed>
|
||||
{(server) => (
|
||||
<div class="settings-section">
|
||||
<Show when={multiple()}>
|
||||
<h3 class="settings-section-title">{serverName(server) || ServerConnection.key(server)}</h3>
|
||||
</Show>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
|
||||
</div>
|
||||
</div>
|
||||
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -127,11 +119,9 @@ export const SettingsProjects: Component = () => {
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="settings-section">
|
||||
<Show when={multiple()}>
|
||||
<h3 class="settings-section-title">
|
||||
{serverName(group.server) || ServerConnection.key(group.server)}
|
||||
</h3>
|
||||
</Show>
|
||||
<h3 class="settings-section-title">
|
||||
{serverName(group.server) || ServerConnection.key(group.server)}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<For each={group.projects}>
|
||||
{(project) => <ProjectRow project={project} server={group.server} />}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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,7 +6,6 @@ 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(
|
||||
@@ -18,7 +17,6 @@ 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)
|
||||
@@ -55,7 +53,6 @@ export function SessionUIProvider(
|
||||
data={sessionUIData()}
|
||||
directory={directory()}
|
||||
sessionID={params.id}
|
||||
shellOutput={(input) => serverSDK.api.shell.output(input)}
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
|
||||
@@ -245,14 +245,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
})
|
||||
},
|
||||
initializeDraftWorktrees(server: ServerConnection.Key, directory: string, worktree: string) {
|
||||
setStore(
|
||||
(tab) => tab.type === "draft" && tab.server === server && tab.directory === directory && !tab.worktree,
|
||||
produce((tab) => {
|
||||
if (tab.type === "draft") tab.worktree = worktree
|
||||
}),
|
||||
)
|
||||
},
|
||||
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||
// Keep the replacement and navigation atomic so /new-session never renders
|
||||
// after its backing draft tab has been removed from the store.
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
mergeWorkspaceSessionInventory,
|
||||
sessionsForWorkspace,
|
||||
workspaceInventory,
|
||||
workspaceSelectionDestination,
|
||||
} from "./paths"
|
||||
|
||||
describe("isWorkspaceDirectory", () => {
|
||||
@@ -42,19 +41,6 @@ describe("isWorkspaceSelection", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspaceSelectionDestination", () => {
|
||||
test("preserves local intent for the main selection and project root", () => {
|
||||
expect(workspaceSelectionDestination("main", "/repo")).toBe("main")
|
||||
expect(workspaceSelectionDestination("/repo/", "/repo")).toBe("main")
|
||||
expect(workspaceSelectionDestination("c:\\repo\\", "C:\\repo")).toBe("main")
|
||||
})
|
||||
|
||||
test("preserves workspace intent without carrying project-specific paths", () => {
|
||||
expect(workspaceSelectionDestination("create", "/repo")).toBe("create")
|
||||
expect(workspaceSelectionDestination("/workspaces/feature", "/repo")).toBe("create")
|
||||
})
|
||||
})
|
||||
|
||||
test("groups and filters workspace inventory by project", () => {
|
||||
const inventory = workspaceInventory([
|
||||
{
|
||||
|
||||
@@ -106,10 +106,6 @@ export function isWorkspaceSelection(project: WorkspaceProject | undefined, sele
|
||||
return isWorkspaceDirectory(project, selection)
|
||||
}
|
||||
|
||||
export function workspaceSelectionDestination(selection: string, projectWorktree: string) {
|
||||
return selection === "main" || sameDirectory(selection, projectWorktree) ? "main" : "create"
|
||||
}
|
||||
|
||||
export function workspaceDefaultSelection(
|
||||
setting: WorkspaceDefaultDestination,
|
||||
lastUsed: WorkspaceLastUsed | undefined,
|
||||
|
||||
@@ -113,32 +113,6 @@ 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>({
|
||||
|
||||
@@ -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 or Git package specifier")),
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("remove", {
|
||||
|
||||
@@ -13,8 +13,10 @@ 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.isInstallablePackage(input.package))))
|
||||
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
|
||||
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"),
|
||||
)
|
||||
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; text?: string; mention?: PromptMention }
|
||||
export type PromptSkillAttachment = { id: string; name: 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; adopted?: Array<string> }
|
||||
data: { projectID: string; directory: string; previous: string }
|
||||
}
|
||||
|
||||
export type CommandUpdated = {
|
||||
@@ -2740,7 +2740,6 @@ 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"
|
||||
@@ -3016,7 +3015,6 @@ 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"
|
||||
@@ -3292,7 +3290,6 @@ 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"
|
||||
|
||||
@@ -617,22 +617,7 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
case "worktree.resolved": {
|
||||
for (const [sessionID, info] of Object.entries(store.session.info)) {
|
||||
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,
|
||||
)
|
||||
const adopted = Worktree.adopt({ projectID: info.projectID, directory: info.location.directory }, event.data)
|
||||
if (!adopted) continue
|
||||
setStore("session", "info", sessionID, "projectID", adopted.projectID)
|
||||
setStore("session", "info", sessionID, "subpath", adopted.subpath)
|
||||
|
||||
@@ -154,106 +154,6 @@ 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[] = []
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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" })
|
||||
})
|
||||
})
|
||||
@@ -103,6 +103,7 @@
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
|
||||
@@ -54,10 +54,9 @@ 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.slice("@ai-sdk/".length)}`,
|
||||
package: `@opencode-ai/ai/providers/${input.packageName === "@ai-sdk/cerebras" ? "cerebras" : "togetherai"}`,
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
|
||||
@@ -334,7 +334,6 @@ 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" ||
|
||||
@@ -346,7 +345,6 @@ 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" ||
|
||||
|
||||
@@ -442,7 +442,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||
command: runtime.session.command,
|
||||
rename: runtime.session.rename,
|
||||
move: runtime.session.move,
|
||||
synthetic: runtime.session.synthetic,
|
||||
interrupt: (input) =>
|
||||
runtime.session
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
@@ -41,6 +42,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
CloudflareAIGatewayPlugin,
|
||||
CloudflareWorkersAIPlugin,
|
||||
CoherePlugin,
|
||||
DeepInfraPlugin,
|
||||
GatewayPlugin,
|
||||
GithubCopilotPlugin,
|
||||
GitLabPlugin,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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)
|
||||
},
|
||||
})
|
||||
@@ -20,7 +20,6 @@ export interface Interface {
|
||||
| "generate"
|
||||
| "command"
|
||||
| "rename"
|
||||
| "move"
|
||||
| "resume"
|
||||
| "switchAgent"
|
||||
| "switchModel"
|
||||
@@ -77,7 +76,6 @@ export const layerWithCell = (cell: Cell) =>
|
||||
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
|
||||
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
|
||||
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
|
||||
move: (input) => require(cell, (runtime) => runtime.session.move(input)),
|
||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||
switchAgent: (input) => require(cell, (runtime) => runtime.session.switchAgent(input)),
|
||||
switchModel: (input) => require(cell, (runtime) => runtime.session.switchModel(input)),
|
||||
|
||||
@@ -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", ""], refresh: true })).entrypoint
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).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 =
|
||||
|
||||
@@ -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, gte, isNull, lte } from "drizzle-orm"
|
||||
import { and, asc, desc, eq } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -104,12 +104,7 @@ 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
|
||||
@@ -140,38 +135,9 @@ 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,
|
||||
...(adopted.length ? { adopted: adopted.map((candidate) => candidate.id) } : {}),
|
||||
},
|
||||
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
|
||||
{
|
||||
commit: () =>
|
||||
db
|
||||
@@ -290,7 +256,12 @@ const layer = Layer.effect(
|
||||
return node ? ID.make(node) : undefined
|
||||
})
|
||||
|
||||
const hgDiscover = Effect.fnUntraced(function* (dotHg: AbsolutePath) {
|
||||
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 worktree = AbsolutePath.make(path.dirname(dotHg))
|
||||
const store = AbsolutePath.make(dotHg)
|
||||
const previous = yield* cached(store)
|
||||
@@ -304,15 +275,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
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
|
||||
const repo = yield* git.repo.discover(input)
|
||||
if (repo) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
|
||||
@@ -332,14 +295,10 @@ const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
const hg = marker && path.basename(marker) === ".hg" ? yield* hgDiscover(AbsolutePath.make(marker)) : undefined
|
||||
const hg = yield* hgDiscover(input)
|
||||
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
|
||||
return yield* persist({
|
||||
id: ID.make(Hash.fast(`directory:${directory}`)),
|
||||
directory,
|
||||
canonical: directory,
|
||||
vcs: undefined,
|
||||
})
|
||||
const directory = AbsolutePath.make(path.parse(input).root)
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
})
|
||||
|
||||
return Service.of({ list, update, resolve })
|
||||
|
||||
@@ -49,7 +49,6 @@ 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")],
|
||||
|
||||
@@ -48,6 +48,7 @@ import { Skill } from "./skill.js"
|
||||
import { Job } from "./job.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellOutput } from "./shell/output.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
@@ -596,11 +597,7 @@ const layer = Layer.effect(
|
||||
yield* plugins.flush
|
||||
return yield* Image.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 skills = 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,
|
||||
@@ -698,9 +695,9 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const output = terminal.retained
|
||||
? yield* shell
|
||||
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
|
||||
? yield* ShellOutput.preview(started, shell).pipe(
|
||||
Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())),
|
||||
)
|
||||
: missingShellOutput()
|
||||
return { shell: terminal.info, output }
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
@@ -722,7 +719,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.get(input.skill)
|
||||
const skill = (yield* skills.list()).find((item) => item.id === input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* bus.publish(
|
||||
SessionEvent.Skill.Activated,
|
||||
@@ -974,22 +971,16 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
|
||||
const selected = yield* Effect.gen(function* () {
|
||||
if (!requested?.length) return undefined
|
||||
const skillService = yield* skills
|
||||
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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
|
||||
})
|
||||
@@ -1130,9 +1121,6 @@ function positiveInt(value: string | null) {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
// Mirrors the shell tool's in-memory preview safety limit.
|
||||
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
|
||||
@@ -139,11 +139,7 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
(file) =>
|
||||
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
|
||||
) ?? []
|
||||
const skills =
|
||||
message.skills?.flatMap((skill) =>
|
||||
skill.text === undefined ? [] : [`[Skill activated: ${skill.name}]\n${skill.text}`],
|
||||
) ?? []
|
||||
return [...skills, `[User]: ${message.text}`, ...files].join("\n")
|
||||
return [`[User]: ${message.text}`, ...files].join("\n")
|
||||
}
|
||||
if (message.type === "location-switched")
|
||||
return `[User]: The working directory has been changed to ${message.location.directory}.`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionProjector from "./projector.js"
|
||||
|
||||
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
@@ -23,7 +23,6 @@ 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 }>
|
||||
@@ -479,34 +478,18 @@ 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 candidates = [
|
||||
...new Set(
|
||||
[event.data.previous, Project.ID.global, ...(event.data.adopted ?? [])].filter(
|
||||
(id) => id !== event.data.projectID,
|
||||
),
|
||||
),
|
||||
]
|
||||
if (candidates.length === 0) return
|
||||
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
|
||||
if (stale.length === 0) return
|
||||
const rows = yield* db
|
||||
.select({
|
||||
id: SessionTable.id,
|
||||
directory: SessionTable.directory,
|
||||
projectID: SessionTable.project_id,
|
||||
canonical: ProjectTable.worktree,
|
||||
})
|
||||
.select({ id: SessionTable.id, directory: SessionTable.directory })
|
||||
.from(SessionTable)
|
||||
.innerJoin(ProjectTable, eq(SessionTable.project_id, ProjectTable.id))
|
||||
.where(
|
||||
and(
|
||||
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")),
|
||||
),
|
||||
),
|
||||
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")),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
@@ -514,15 +497,12 @@ const layer = Layer.effectDiscard(
|
||||
yield* Effect.forEach(
|
||||
rows,
|
||||
(row) => {
|
||||
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
|
||||
if (!FSUtil.contains(event.data.directory, row.directory)) return Effect.void
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
project_id: event.data.projectID,
|
||||
path: RelativePath.make(path.relative(event.data.directory, directory).replaceAll("\\", "/")),
|
||||
path: RelativePath.make(path.relative(event.data.directory, row.directory).replaceAll("\\", "/")),
|
||||
// Self-assignment suppresses the column's $onUpdate: adoption is not activity.
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
|
||||
@@ -236,7 +236,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
]
|
||||
case "user":
|
||||
const content = [
|
||||
...(message.skills ?? []).flatMap((skill) => (skill.text === undefined ? [] : [Message.text(skill.text)])),
|
||||
...(message.text === "" ? [] : [Message.text(message.text)]),
|
||||
...userAttachmentContent(message.files ?? []),
|
||||
]
|
||||
|
||||
@@ -220,7 +220,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export * as ShellOutput from "./output.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import type { Info } from "@opencode-ai/schema/shell"
|
||||
import { Config } from "../config.js"
|
||||
import { Shell } from "../shell.js"
|
||||
import { ToolOutput } from "../tool-output.js"
|
||||
|
||||
export const preview = Effect.fn("ShellOutput.preview")(function* (info: Info, shell: Shell.Interface) {
|
||||
const config = yield* Config.Service
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - maxBytes),
|
||||
limit: maxBytes,
|
||||
})
|
||||
const lines = page.output.split("\n")
|
||||
if (page.output.endsWith("\n")) lines.pop()
|
||||
const truncated = latest.size > maxBytes || lines.length > maxLines
|
||||
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return { ...page, output: `${output || "(no output)"}${notice}`, truncated }
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
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"
|
||||
@@ -53,21 +52,6 @@ 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>>
|
||||
}
|
||||
@@ -80,7 +64,6 @@ export type Draft = {
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
@@ -115,9 +98,6 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("Skill.get")(function* (id) {
|
||||
return state.get().skills.get(id)
|
||||
}),
|
||||
list: Effect.fn("Skill.list")(function* () {
|
||||
return Array.from(state.get().skills.values())
|
||||
}),
|
||||
|
||||
@@ -26,6 +26,7 @@ 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>"]),
|
||||
|
||||
@@ -12,9 +12,9 @@ import { PluginRuntime } from "../../plugin/runtime.js"
|
||||
import { NonNegativeInt } from "../../schema.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { Shell } from "../../shell.js"
|
||||
import { ShellOutput } from "../../shell/output.js"
|
||||
import { ShellParse } from "../../shell/parse.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
@@ -239,29 +239,11 @@ export const Plugin = {
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fnUntraced(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - maxBytes),
|
||||
limit: maxBytes,
|
||||
})
|
||||
const lines = page.output.split("\n")
|
||||
if (page.output.endsWith("\n")) lines.pop()
|
||||
const truncated = latest.size > maxBytes || lines.length > maxLines
|
||||
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return {
|
||||
output: `${output || "(no output)"}${notice}`,
|
||||
truncated,
|
||||
}
|
||||
})
|
||||
|
||||
const settleShell = Effect.fnUntraced(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
const capture = yield* ShellOutput.preview(info, shell).pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
)
|
||||
|
||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||
// fails output encoding, so omit it when the process has no exit code.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -8,6 +9,7 @@ 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" }),
|
||||
@@ -45,7 +47,8 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* skills.get(input.id)
|
||||
const current = yield* skills.list()
|
||||
const skill = current.find((skill) => skill.id === input.id)
|
||||
if (!skill) return yield* unableToLoad(input.id)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
@@ -56,7 +59,19 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
return { name: skill.name, ...(yield* Skill.prepare(fs, skill)) }
|
||||
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),
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
|
||||
@@ -61,8 +61,8 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Cerebras, DeepInfra, and Together AI settings, headers, and reasoning options to native providers", () => {
|
||||
for (const name of ["cerebras", "deepinfra", "togetherai"]) {
|
||||
test("maps Cerebras and Together AI settings, headers, and reasoning options to native providers", () => {
|
||||
for (const name of ["cerebras", "togetherai"]) {
|
||||
expect(
|
||||
map(`@ai-sdk/${name}`, {
|
||||
apiKey: "secret",
|
||||
|
||||
@@ -885,12 +885,6 @@ 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",
|
||||
@@ -969,7 +963,6 @@ 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"],
|
||||
@@ -1094,11 +1087,6 @@ 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" } }),
|
||||
)
|
||||
@@ -1125,10 +1113,6 @@ describe("ModelResolver", () => {
|
||||
expect(cerebras.route.id).toBe("cerebras-chat")
|
||||
expect(cerebras.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(String(cerebras.provider)).toBe("test-provider")
|
||||
expect(deepinfra.route.id).toBe("deepinfra-chat")
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://deepinfra.example/provider-root/openai")
|
||||
expect(deepinfra.route.defaults.providerOptions).toEqual({ reasoningEffort: "none" })
|
||||
expect(String(deepinfra.provider)).toBe("test-provider")
|
||||
expect(togetherai.route.id).toBe("togetherai-chat")
|
||||
expect(togetherai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(String(togetherai.provider)).toBe("test-provider")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -22,39 +21,6 @@ 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")
|
||||
@@ -80,33 +46,6 @@ 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()
|
||||
@@ -177,92 +116,6 @@ 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", () => {
|
||||
|
||||
@@ -140,7 +140,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
|
||||
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
|
||||
rename: overrides.session?.rename ?? (() => Effect.die("unused session.rename")),
|
||||
move: overrides.session?.move ?? (() => Effect.die("unused session.move")),
|
||||
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
|
||||
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
|
||||
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
|
||||
|
||||
@@ -137,7 +137,6 @@ describe("fromPromise", () => {
|
||||
switchAgent: (input) => Effect.sync(() => seen.push(input)),
|
||||
switchModel: (input) => Effect.sync(() => seen.push(input)),
|
||||
rename: (input) => Effect.sync(() => seen.push(input)),
|
||||
move: (input) => Effect.sync(() => seen.push(input)),
|
||||
wait: (input) => Effect.sync(() => seen.push(input)),
|
||||
},
|
||||
})
|
||||
@@ -158,9 +157,6 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(await ctx.session.rename({ sessionID: "ses_success", title: "Renamed" })).toBeUndefined()
|
||||
expect(
|
||||
await ctx.session.move({ sessionID: "ses_success", directory: "/destination", delivery: "queue" }),
|
||||
).toBeUndefined()
|
||||
expect(await ctx.session.wait({ sessionID: "ses_success" })).toBeUndefined()
|
||||
},
|
||||
}),
|
||||
@@ -173,11 +169,6 @@ describe("fromPromise", () => {
|
||||
model: { providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5") },
|
||||
},
|
||||
{ sessionID: Session.ID.make("ses_success"), title: "Renamed" },
|
||||
{
|
||||
sessionID: Session.ID.make("ses_success"),
|
||||
directory: AbsolutePath.make("/destination"),
|
||||
delivery: "queue",
|
||||
},
|
||||
{ sessionID: Session.ID.make("ses_success") },
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
@@ -20,6 +21,7 @@ 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" },
|
||||
|
||||
@@ -145,54 +145,24 @@ async function rootCommit(dir: string) {
|
||||
}
|
||||
|
||||
describe("Project.resolve", () => {
|
||||
it.live("creates distinct deterministic projects for exact markerless directories", () =>
|
||||
it.live("returns global for non-git directory", () =>
|
||||
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).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.id).toBe(Project.ID.make("global"))
|
||||
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
|
||||
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(
|
||||
@@ -441,48 +411,6 @@ 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(
|
||||
|
||||
@@ -6,7 +6,6 @@ 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,7 +23,6 @@ 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"
|
||||
@@ -232,13 +231,6 @@ 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 })
|
||||
@@ -269,7 +261,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
})
|
||||
expect(requests[0]?.generation).toBeUndefined()
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
||||
])
|
||||
|
||||
@@ -5,7 +5,6 @@ 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"
|
||||
@@ -31,6 +30,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
@@ -96,37 +96,14 @@ 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 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"))
|
||||
const originalUpdated = created.time.updated
|
||||
|
||||
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)
|
||||
@@ -137,25 +114,10 @@ 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).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(created.projectID).toBe(Project.ID.global)
|
||||
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]))
|
||||
@@ -163,37 +125,16 @@ describe("Session.create", () => {
|
||||
projectID: project.id,
|
||||
location: ref,
|
||||
subpath: undefined,
|
||||
time: { updated: DateTime.makeUnsafe(before.time_updated) },
|
||||
time: { updated: originalUpdated },
|
||||
})
|
||||
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",
|
||||
"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,
|
||||
})
|
||||
expect(log.map((event) => event.type)).toEqual(["session.created"])
|
||||
expect(yield* session.messages({ sessionID: created.id })).toEqual([])
|
||||
// Repeated resolution announces the directory's identity exactly once.
|
||||
const announced = yield* db
|
||||
.select({ type: EventTable.type })
|
||||
@@ -206,46 +147,6 @@ 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
|
||||
@@ -988,6 +889,61 @@ describe("Session.create", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("truncates direct shell output using the shared shell preview policy", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
const bytes = ToolOutput.MAX_BYTES + 1024
|
||||
const command =
|
||||
process.platform === "win32"
|
||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end')`
|
||||
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||
|
||||
yield* session.shell({ sessionID: created.id, command })
|
||||
|
||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
||||
expect(shell?.output?.truncated).toBe(true)
|
||||
expect(shell?.output?.output).not.toContain("output-start")
|
||||
expect(shell?.output?.output).toContain("output-end")
|
||||
expect(shell?.output?.output).toContain("output truncated; full output saved to:")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies configured output limits to direct shell commands", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(directory, "opencode.json"),
|
||||
JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }),
|
||||
),
|
||||
)
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
const command =
|
||||
process.platform === "win32"
|
||||
? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
|
||||
: "printf 'one\\ntwo\\nthree'"
|
||||
|
||||
yield* session.shell({ sessionID: created.id, command })
|
||||
|
||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
||||
expect(shell?.output?.truncated).toBe(true)
|
||||
expect(shell?.output?.output).not.toContain("one")
|
||||
expect(shell?.output?.output.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
|
||||
expect(shell?.output?.output).toContain("output truncated; full output saved to:")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("still emits shell ended for a failing command", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -1278,13 +1234,6 @@ 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) },
|
||||
},
|
||||
{
|
||||
@@ -1314,11 +1263,7 @@ 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}]`,
|
||||
skills: [{ id: "effect", name: "[redacted:skill-name:0]", text: "[redacted:skill:0]" }],
|
||||
},
|
||||
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
|
||||
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
|
||||
])
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ const it = testEffect(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Project.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
Agent.node,
|
||||
@@ -201,7 +200,6 @@ 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) => {
|
||||
@@ -212,7 +210,7 @@ const setup = Effect.gen(function* () {
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: (yield* projects.resolve(AbsolutePath.make("/project"))).id,
|
||||
project_id: Project.ID.global,
|
||||
slug: "generate-test",
|
||||
directory: "/project",
|
||||
title: "Generate test",
|
||||
|
||||
@@ -205,44 +205,6 @@ Recent work
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers each prepared skill once before the prompt", () => {
|
||||
const effect = SkillAttachment.make({
|
||||
id: Skill.ID.make("effect"),
|
||||
name: Skill.Name.make("Effect"),
|
||||
text: "<skill_content>Use Effect</skill_content>",
|
||||
})
|
||||
const api = SkillAttachment.make({
|
||||
id: Skill.ID.make("api-design"),
|
||||
name: Skill.Name.make("API design"),
|
||||
text: "<skill_content>Design APIs</skill_content>",
|
||||
})
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-skill-content"),
|
||||
type: "user",
|
||||
text: "Use @effect and @api-design",
|
||||
skills: [effect, api, SkillAttachment.make({ id: effect.id, name: effect.name })],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages).toEqual([
|
||||
Message.make({
|
||||
id: id("user-skill-content"),
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "<skill_content>Use Effect</skill_content>" },
|
||||
{ type: "text", text: "<skill_content>Design APIs</skill_content>" },
|
||||
{ type: "text", text: "Use @effect and @api-design" },
|
||||
],
|
||||
metadata: {},
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("does not inject skill content for reference-only attachments", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
@@ -24,20 +23,18 @@ 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 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.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 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(
|
||||
@@ -59,7 +56,7 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
describe("Session.skill", () => {
|
||||
it.effect("materializes mentioned skills on their owning prompt", () =>
|
||||
it.effect("keeps skill mentions as references on a normal prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
@@ -70,63 +67,26 @@ describe("Session.skill", () => {
|
||||
yield* sessions.prompt({
|
||||
id,
|
||||
sessionID: session.id,
|
||||
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" } },
|
||||
],
|
||||
text: "Apply @effect",
|
||||
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, 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 })).toEqual([
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id,
|
||||
type: "user",
|
||||
text: "Apply @effect and @effect",
|
||||
text: "Apply @effect",
|
||||
skills: [
|
||||
{
|
||||
id: "effect",
|
||||
name: "Effect",
|
||||
text: Skill.toModelOutput(info, []),
|
||||
mention: { start: 6, end: 13, text: "@effect" },
|
||||
},
|
||||
{
|
||||
id: "effect",
|
||||
name: "Effect",
|
||||
mention: { start: 18, end: 25, text: "@effect" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("excludes mentioned skills when forking before their prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const session = yield* sessions.create({ location })
|
||||
const initial = SessionMessage.ID.make("msg_before_skill_attachment")
|
||||
const selected = SessionMessage.ID.make("msg_fork_skill_attachment")
|
||||
|
||||
yield* sessions.prompt({ id: initial, sessionID: session.id, text: "Before the skill", resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
|
||||
yield* sessions.prompt({
|
||||
id: selected,
|
||||
sessionID: session.id,
|
||||
text: "Apply @effect",
|
||||
skills: [{ id: info.id, mention: { start: 6, end: 13, text: "@effect" } }],
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
|
||||
const forked = yield* sessions.fork({ sessionID: session.id, boundary: { type: "before", messageID: selected } })
|
||||
|
||||
expect(yield* sessions.messages({ sessionID: forked.id })).toEqual([
|
||||
expect.objectContaining({ type: "user", text: "Before the skill" }),
|
||||
])
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -31,8 +31,6 @@ 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,6 +59,7 @@ describe("SkillInstructions", () => {
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"When the user references a skill with @skill-id, load that skill with the skill tool.",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <id>effect</id>",
|
||||
|
||||
@@ -74,7 +74,6 @@ 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),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -274,13 +274,6 @@ function initConsoleTransport() {
|
||||
return
|
||||
}
|
||||
|
||||
for (const stream of [process.stdout, process.stderr]) {
|
||||
stream.on("error", (error) => {
|
||||
if (!isBrokenPipe(error)) throw error
|
||||
log.transports.console.level = false
|
||||
})
|
||||
}
|
||||
|
||||
const writeConsole = log.transports.console.writeFn.bind(log.transports.console)
|
||||
log.transports.console.writeFn = (options) => {
|
||||
try {
|
||||
|
||||
@@ -101,7 +101,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
wireWindowRecovery(win, id, () => relaunchHandler(), registry.isQuitting)
|
||||
wireWindowRecovery(win, id, () => relaunchHandler())
|
||||
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
|
||||
wireRendererHeaders(win)
|
||||
state.manage(win)
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { nativeT } from "../native/translations"
|
||||
import { isRendererUrl } from "./protocol"
|
||||
import { safeWindowURL } from "./state"
|
||||
import { makeUnresponsiveSampler } from "./unresponsive"
|
||||
|
||||
@@ -14,7 +13,7 @@ export const makeWindowRecovery = Effect.gen(function* () {
|
||||
const logging = yield* DesktopLogging.Service
|
||||
const createUnresponsiveSampler = yield* makeUnresponsiveSampler
|
||||
|
||||
function wireWindowRecovery(win: BrowserWindow, name: string, relaunch: () => void, isQuitting: () => boolean) {
|
||||
function wireWindowRecovery(win: BrowserWindow, name: string, relaunch: () => void) {
|
||||
let showing = false
|
||||
const sampler = createUnresponsiveSampler(win, name)
|
||||
|
||||
@@ -41,10 +40,10 @@ export const makeWindowRecovery = Effect.gen(function* () {
|
||||
}
|
||||
|
||||
const show = async (message: string, detail: string, wait: boolean) => {
|
||||
if (showing || win.isDestroyed() || isQuitting()) return
|
||||
if (showing || win.isDestroyed()) return
|
||||
showing = true
|
||||
try {
|
||||
while (!win.isDestroyed() && !isQuitting()) {
|
||||
while (!win.isDestroyed()) {
|
||||
const actions: { id: RecoveryAction; label: string }[] = wait
|
||||
? [
|
||||
{ id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") },
|
||||
@@ -79,11 +78,6 @@ export const makeWindowRecovery = Effect.gen(function* () {
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean,
|
||||
) => {
|
||||
if (isQuitting()) return
|
||||
if (isMainFrame && errorCode === -102 && process.env.ELECTRON_RENDERER_URL && isRendererUrl(validatedURL)) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
runFork(
|
||||
scoped(
|
||||
"window",
|
||||
@@ -119,7 +113,6 @@ export const makeWindowRecovery = Effect.gen(function* () {
|
||||
})
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
sampler.stopAndFlush()
|
||||
if (isQuitting()) return
|
||||
runFork(
|
||||
scoped(
|
||||
"window",
|
||||
@@ -137,7 +130,6 @@ export const makeWindowRecovery = Effect.gen(function* () {
|
||||
)
|
||||
})
|
||||
win.on("unresponsive", () => {
|
||||
if (isQuitting()) return
|
||||
runFork(
|
||||
scoped("window", Effect.logError("renderer unresponsive", { window: name, currentURL: safeWindowURL(win) })),
|
||||
)
|
||||
|
||||
@@ -54,9 +54,7 @@ describe("window registry", () => {
|
||||
const app = setup()
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("b", { name: "b" })
|
||||
expect(app.registry.isQuitting()).toBe(false)
|
||||
app.registry.setQuitting()
|
||||
expect(app.registry.isQuitting()).toBe(true)
|
||||
expect(app.registry.closed("a")).toBe(false)
|
||||
expect(app.registry.closed("b")).toBe(false)
|
||||
expect(app.state.stored).toEqual(["a", "b"])
|
||||
@@ -80,7 +78,6 @@ describe("window registry", () => {
|
||||
app.registry.register("b", { name: "b" })
|
||||
app.registry.setQuitting()
|
||||
app.registry.setQuitting(false)
|
||||
expect(app.registry.isQuitting()).toBe(false)
|
||||
expect(app.registry.closed("a")).toBe(true)
|
||||
expect(app.state.stored).toEqual(["b"])
|
||||
})
|
||||
|
||||
@@ -16,9 +16,6 @@ export function createWindowRegistry<W>(persistence: {
|
||||
|
||||
return {
|
||||
persisted,
|
||||
isQuitting() {
|
||||
return quitting
|
||||
},
|
||||
setQuitting(value = true) {
|
||||
quitting = value
|
||||
},
|
||||
|
||||
@@ -57,7 +57,6 @@ export type SessionDomain = Pick<
|
||||
| "synthetic"
|
||||
| "interrupt"
|
||||
| "rename"
|
||||
| "move"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
|
||||
@@ -366,7 +366,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
|
||||
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
|
||||
rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
|
||||
move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move),
|
||||
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
|
||||
context: adaptApiMethod(SessionEndpoints["session.context"], host.session.context),
|
||||
},
|
||||
|
||||
@@ -57,7 +57,6 @@ export type SessionDomain = Pick<
|
||||
| "synthetic"
|
||||
| "interrupt"
|
||||
| "rename"
|
||||
| "move"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
|
||||
@@ -16200,9 +16200,6 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ 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" })
|
||||
|
||||
|
||||
@@ -57,46 +57,27 @@ 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; readonly workspaceID?: string },
|
||||
event: {
|
||||
readonly projectID: string
|
||||
readonly directory: string
|
||||
readonly previous: string
|
||||
readonly adopted?: ReadonlyArray<string>
|
||||
},
|
||||
session: { readonly projectID: string; readonly directory: string },
|
||||
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
|
||||
) {
|
||||
if (session.workspaceID) return
|
||||
if (
|
||||
session.projectID !== event.previous &&
|
||||
session.projectID !== Project.ID.global &&
|
||||
!event.adopted?.includes(session.projectID)
|
||||
)
|
||||
return
|
||||
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
|
||||
if (session.projectID === event.projectID) 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
|
||||
const inside =
|
||||
session.directory === event.directory ||
|
||||
session.directory.startsWith(event.directory + "/") ||
|
||||
session.directory.startsWith(event.directory + "\\")
|
||||
if (!inside) return
|
||||
return {
|
||||
projectID: event.projectID,
|
||||
subpath: key === parent ? undefined : directory.slice(root.length + 1),
|
||||
subpath:
|
||||
session.directory === event.directory
|
||||
? undefined
|
||||
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
@@ -80,16 +79,6 @@ 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)(
|
||||
|
||||
@@ -173,10 +173,6 @@ 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)
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
ShellOutputInput,
|
||||
ShellOutputOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
|
||||
@@ -46,7 +40,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
data: Data
|
||||
directory: string
|
||||
sessionID?: string
|
||||
shellOutput?: (input: ShellOutputInput) => Promise<ShellOutputOutput>
|
||||
onNavigateToSession?: NavigateToSessionFn
|
||||
onSessionHref?: SessionHrefFn
|
||||
}) => {
|
||||
@@ -62,7 +55,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
navigateToSession: props.onNavigateToSession,
|
||||
sessionHref: props.onSessionHref,
|
||||
shellOutput: props.shellOutput,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
@@ -1295,41 +1294,15 @@ 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((pending() && streamed()) || props.output || "").replace(/\r\n?/g, "\n"))
|
||||
const output = createMemo(() => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n"))
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -86,15 +86,7 @@ export function DevToolsBar() {
|
||||
const offEscape = keymap.intercept(
|
||||
"key",
|
||||
({ event }) => {
|
||||
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
|
||||
}
|
||||
if (!panel() || event.name !== "escape") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
@@ -360,7 +352,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) => (
|
||||
|
||||
@@ -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,14 +759,6 @@ export function Autocomplete(props: {
|
||||
hide()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prompt.clear",
|
||||
title: "Dismiss autocomplete",
|
||||
group: "Autocomplete",
|
||||
run() {
|
||||
hide(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prompt.autocomplete.select",
|
||||
title: "Select autocomplete item",
|
||||
|
||||
@@ -102,7 +102,6 @@ export type PromptRef = {
|
||||
}
|
||||
|
||||
const DRAFT_RETENTION_MIN_CHARS = 20
|
||||
const revealedPromptMetadata = new WeakSet<object>()
|
||||
|
||||
function randomIndex(count: number) {
|
||||
if (count <= 0) return 0
|
||||
@@ -987,19 +986,9 @@ 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") },
|
||||
{
|
||||
bind: "ctrl+c",
|
||||
title: "Exit shell mode",
|
||||
group: "Prompt",
|
||||
enabled: () => store.prompt.text === "",
|
||||
run: () => setStore("mode", "normal"),
|
||||
},
|
||||
],
|
||||
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1619,20 +1608,12 @@ export function Prompt(props: PromptProps) {
|
||||
return promptDisplay().agentColor ?? theme.border.default
|
||||
})
|
||||
const agentLabel = createMemo(() => (store.mode === "shell" ? "Shell" : promptDisplay().agentLabel))
|
||||
const animateMetadata = !revealedPromptMetadata.has(local)
|
||||
const metadataAnimationsEnabled = () => animationsEnabled() && animateMetadata
|
||||
const agentMetaAlpha = createFadeIn(() => !!agentLabel(), metadataAnimationsEnabled)
|
||||
const modelMetaAlpha = createFadeIn(
|
||||
() => !!promptDisplay().agentLabel && store.mode === "normal",
|
||||
metadataAnimationsEnabled,
|
||||
)
|
||||
const agentMetaAlpha = createFadeIn(() => !!agentLabel(), animationsEnabled)
|
||||
const modelMetaAlpha = createFadeIn(() => !!promptDisplay().agentLabel && store.mode === "normal", animationsEnabled)
|
||||
const variantMetaAlpha = createFadeIn(
|
||||
() => !!promptDisplay().agentLabel && store.mode === "normal" && !!promptDisplay().variant,
|
||||
metadataAnimationsEnabled,
|
||||
animationsEnabled,
|
||||
)
|
||||
createEffect(() => {
|
||||
if (agentLabel()) revealedPromptMetadata.add(local)
|
||||
})
|
||||
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
|
||||
const footerInput = () => ({ sessionID: props.sessionID, mode: store.mode })
|
||||
|
||||
@@ -1641,10 +1622,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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -38,7 +38,6 @@ 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
|
||||
@@ -267,11 +266,6 @@ 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 [
|
||||
@@ -1056,12 +1050,7 @@ 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,12 +423,6 @@ 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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user