Compare commits

...
Author SHA1 Message Date
Aiden Cline 74e128887e fix(ai): flatten tool namespaces 2026-09-01 11:05:09 -05:00
Aiden Cline e01ca88243 refactor(ai): simplify namespace handling 2026-09-01 10:48:42 -05:00
Aiden Cline 6806ac451b refactor(ai): simplify namespace propagation 2026-09-01 10:44:32 -05:00
Aiden Cline 8d6d7f4942 feat(ai): add tool namespaces 2026-09-01 00:22:36 -05:00
Aiden Cline 000b42d204 refactor(core): nest code mode catalog (#46541) 2026-09-01 00:01:31 -05:00
Aiden Cline 02440f6715 feat(codemode): label item and value schema comments (#46542) 2026-08-31 23:54:58 -05:00
Brendan AllanandDavid Hill d46ed9e9db Session message style (#46538)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-09-01 04:49:16 +00:00
opencode-agent[bot]andBrendonovich 71779ae5de fix(app): scope pane visibility to tabs (#46508)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 12:21:42 +08:00
Aiden Cline 4a3e25beab feat(codemode): compact schema constraint comments (#46521) 2026-08-31 23:06:49 -05:00
opencode-agent[bot]andrekram1-node 23fde448ec test(cli): isolate Bun define cache (#46536)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-31 23:01:02 -05:00
opencode-agent[bot] 1137861188 chore: update nix node_modules hashes 2026-09-01 04:00:15 +00:00
Dax Raad 815d4ab9b4 fix(tui): scope connect dialog to location 2026-08-31 23:42:35 -04:00
Dax Raad 5d73a5789f feat(plugin): support live package updates 2026-08-31 23:35:21 -04:00
147 changed files with 3309 additions and 1202 deletions
+2
View File
@@ -969,6 +969,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -978,6 +979,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
}
}
+22 -10
View File
@@ -11,7 +11,7 @@
// Manual `cache: CacheHint` placements on individual parts are preserved and
// count against the four-breakpoint budget; auto only fills remaining slots.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages.js"
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"
const AUTO: CachePolicyObject = {
tools: true,
@@ -50,18 +50,30 @@ interface Budget {
remaining: number
}
const markLastTool = (
tools: ReadonlyArray<ToolDefinition>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<ToolDefinition> => {
const markLastTool = (tools: ReadonlyArray<ToolEntry>, hint: CacheHint, budget: Budget): ReadonlyArray<ToolEntry> => {
if (tools.length === 0) return tools
const last = tools.length - 1
if (tools[last]!.cache || budget.remaining === 0) return tools
const last = tools.findLastIndex((tool) => tool.type === "tool" || tool.tools.some(hasTool))
if (last === -1) return tools
const target = tools[last]!
if (target.type === "namespace") {
const nested = markLastTool(target.tools, hint, budget)
return nested === target.tools
? tools
: tools.map((tool, index) => (index === last ? { ...target, tools: nested } : tool))
}
if (target.cache || budget.remaining === 0) return tools
budget.remaining -= 1
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
return tools.map((tool, index) => (index === last ? new ToolDefinition({ ...target, cache: hint }) : tool))
}
const hasTool = (tool: ToolEntry): boolean => tool.type === "tool" || tool.tools.some(hasTool)
const countToolHints = (tools: ReadonlyArray<ToolEntry>): number =>
tools.reduce(
(count, tool) => count + (tool.type === "tool" ? (tool.cache === undefined ? 0 : 1) : countToolHints(tool.tools)),
0,
)
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
if (system.length === 0) return system
let changed = false
@@ -122,7 +134,7 @@ const markMessages = (
}
const countHints = (request: LLMRequest) =>
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
countToolHints(request.tools) +
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
request.messages.reduce(
(count, message) =>
+4 -3
View File
@@ -12,9 +12,10 @@ import {
LanguageModel,
SystemPart,
ToolChoice,
ToolDefinition,
ToolEntry,
type ContentPart,
type LanguageModelProviderOptions,
type ToolEntryInput,
} from "./schema/index.js"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool.js"
@@ -27,7 +28,7 @@ export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageM
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input>
readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly tools?: ReadonlyArray<ToolEntryInput>
readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: NoInfer<LanguageModelProviderOptions<SelectedLanguageModel>>
@@ -56,7 +57,7 @@ export const request = <const SelectedLanguageModel extends LanguageModel>(
...rest,
system: SystemPart.content(requestSystem),
messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])],
tools: tools?.map(ToolDefinition.make) ?? [],
tools: tools?.map((tool) => ToolEntry.make(tool)) ?? [],
toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined,
generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration),
providerOptions: requestProviderOptions,
@@ -1009,10 +1009,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const flattened = ProviderShared.flattenToolRequest(request)
const definitions = flattened.tools
const tools =
request.tools.length === 0
definitions.length === 0
? undefined
: request.tools.map((tool) =>
: definitions.map((tool) =>
lowerTool(
breakpoints,
tool,
@@ -1030,7 +1032,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
}))
const messages = yield* lowerMessages(request, breakpoints)
const messages = yield* lowerMessages(flattened.request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
@@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
@@ -427,21 +424,23 @@ const lowerSystem = (
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const flattened = ProviderShared.flattenToolRequest(request)
const tools = flattened.tools
const generation = request.generation
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const toolConfig = (() => {
if (request.tools.length === 0) return undefined
if (tools.length === 0) return undefined
return {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
})()
const system = lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)
const messages = yield* lowerMessages(flattened.request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
+4 -2
View File
@@ -466,6 +466,8 @@ function mapSafetySettings(value: unknown) {
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0
const flattened = ProviderShared.flattenToolRequest(request)
const tools = flattened.tools
const generation = request.generation
const options = resolveOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
@@ -483,7 +485,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
return {
cachedContent: options.cachedContent,
contents: yield* lowerMessages(request),
contents: yield* lowerMessages(flattened.request),
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
@@ -491,7 +493,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
tools: hasTools
? [
{
functionDeclarations: request.tools.map((tool) =>
functionDeclarations: tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
},
+4 -2
View File
@@ -414,10 +414,12 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
tool: (name) => ({ type: "function" as const, function: { name } }),
})
: undefined
const flattened = ProviderShared.flattenToolRequest(request)
const tools = flattened.tools
return {
model: request.model.id,
messages: yield* lowerMessages(request),
tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined,
messages: yield* lowerMessages(flattened.request),
tools: tools.length > 0 ? tools.map(lowerTool) : undefined,
tool_choice: toolChoice,
stream: true as const,
max_tokens: request.generation?.maxTokens,
+38 -8
View File
@@ -166,6 +166,7 @@ export const InputItem = Schema.Union([
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
namespace: Schema.optionalKey(Schema.UndefinedOr(Schema.String)),
arguments: Schema.String,
}),
Schema.Struct({
@@ -287,6 +288,7 @@ export const StreamItem = Schema.StructWithRest(
id: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
namespace: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
}),
@@ -376,6 +378,7 @@ export type Event = Schema.Schema.Type<typeof Event>
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly toolNamespaceHistory?: boolean
readonly lowerMedia?: (input: {
readonly part: MediaPart
readonly media: ProviderShared.NormalizedMedia
@@ -462,6 +465,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
...(id === undefined ? {} : { id }),
call_id: part.id,
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
}
}
@@ -742,14 +746,18 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
adapter: ProviderAdapter,
) {
const generation = request.generation
const flattened = adapter.toolNamespaceHistory === true ? undefined : ProviderShared.flattenToolRequest(request)
const tools =
flattened === undefined ? yield* ProviderShared.requireFlatTools(adapter.name, request.tools) : flattened.tools
const input = flattened?.request ?? request
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
input: yield* lowerMessages(input, adapter),
tools:
request.tools.length === 0
tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
: yield* Effect.forEach(tools, (tool) =>
lowerTool(
adapter.name,
tool,
@@ -1020,11 +1028,20 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
tools: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name ?? "",
namespace: item.namespace,
input: item.arguments ?? "",
providerMetadata: metadata,
}),
},
[...events, LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", providerMetadata: metadata })],
[
...events,
LLMEvent.toolInputStart({
id: item.call_id,
name: item.name ?? "",
namespace: item.namespace,
providerMetadata: metadata,
}),
],
]
}
@@ -1137,14 +1154,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const pending = registered === undefined ? undefined : state.tools[registered]
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
pending === undefined
? ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
namespace: item.namespace,
providerMetadata: metadata,
})
: ToolStream.start(state.tools, id, {
...pending,
namespace: pending.namespace ?? item.namespace,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
@@ -1155,7 +1177,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const resultEvents =
registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
: [
LLMEvent.toolInputStart({
id: callID,
name: item.name,
namespace: item.namespace,
providerMetadata: metadata,
}),
...finished,
]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
+4 -2
View File
@@ -736,6 +736,8 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const flattened = ProviderShared.flattenToolRequest(request)
const tools = flattened.tools
const provider = String(request.model.provider)
const baseURL = request.model.route.endpoint.baseURL
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
@@ -751,13 +753,13 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
const hasActiveTools = request.tools.length > 0
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
messages: yield* lowerMessages(flattened.request, options),
tools:
request.tools.length === 0
? hasHistory
? []
: undefined
: request.tools.map((tool) =>
: tools.map((tool) =>
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
+51 -7
View File
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { LLMRequest, type AIError, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
@@ -66,7 +66,18 @@ const OpenAIResponsesHostedToolItem = Schema.Union([
),
])
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
const OpenAIResponsesNamespace = Schema.Struct({
type: Schema.tag("namespace"),
name: Schema.String,
description: Schema.String,
tools: Schema.Array(OpenResponses.Tool),
})
const OpenAIResponsesTools = Schema.Union([
OpenResponses.Tool,
OpenAIResponsesNamespace,
OpenAIResponsesImageGenerationTool,
])
const OpenAIResponsesToolChoice = Schema.Union([
OpenResponses.ToolChoice,
@@ -89,6 +100,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const adapter = {
id: ADAPTER,
name: NAME,
toolNamespaceHistory: true,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
@@ -111,13 +123,47 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe
return yield* OpenResponses.lowerTool(NAME, tool, inputSchema)
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
function lowerNamespaceTools(
tools: ReadonlyArray<ToolEntry>,
compatibility: Parameters<typeof ToolSchemaProjection.modelCompatibility>[1],
path: ReadonlyArray<string> = [],
): Effect.Effect<ReadonlyArray<typeof OpenResponses.Tool.Type>, AIError> {
return Effect.gen(function* () {
const entries = yield* Effect.forEach(tools, (tool) => {
if (tool.type === "namespace") return lowerNamespaceTools(tool.tools, compatibility, [...path, tool.name])
return OpenResponses.lowerTool(
NAME,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility),
).pipe(Effect.map((lowered) => [{ ...lowered, name: [...path, lowered.name].join("_") }]))
}).pipe(Effect.map((entries) => entries.flat()))
return Array.from(new Map(entries.map((tool) => [tool.name, tool])).values())
})
}
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (
tool: ToolEntry,
compatibility: Parameters<typeof ToolSchemaProjection.modelCompatibility>[1],
) {
if (tool.type === "tool")
return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))
if (tool.description === undefined)
return yield* ProviderShared.invalidRequest("OpenAI Responses tool namespaces require a description")
return {
type: "namespace" as const,
name: tool.name,
description: tool.description,
tools: yield* lowerNamespaceTools(tool.tools, compatibility),
}
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolEntry>) =>
ProviderShared.matchToolChoice(NAME, toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "required" as const,
tool: (name) =>
tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined)
tools.some((tool) => tool.type === "tool" && tool.name === name && nativeImageTool(tool) !== undefined)
? ({ type: "image_generation" } as const)
: { type: "function" as const, name },
})
@@ -137,9 +183,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
})
+40 -1
View File
@@ -8,10 +8,13 @@ import {
InvalidRequestError,
AIError,
HttpContext,
LLMRequest,
Message,
ToolDefinition,
type ContentPart,
type LLMRequest,
type MediaPart,
type TextPart,
type ToolEntry,
type ToolResultPart,
} from "../schema/index.js"
import { isRecord } from "../utils/record.js"
@@ -44,6 +47,7 @@ export const promptCacheKey = (request: LLMRequest): string | undefined => {
export interface ToolAccumulator {
readonly id: string
readonly name: string
readonly namespace?: string
readonly input: string
}
@@ -254,6 +258,41 @@ export const invalidRequest = (message: string, cause?: unknown) =>
reason: new InvalidRequestError({ message, cause }),
})
export const requireFlatTools = Effect.fn("ProviderShared.requireFlatTools")(function* (
protocol: string,
tools: ReadonlyArray<ToolEntry>,
) {
return yield* Effect.forEach(tools, (tool): Effect.Effect<ToolDefinition, AIError> => {
if (tool.type === "namespace") return Effect.fail(invalidRequest(`${protocol} does not support tool namespaces`))
return Effect.succeed(tool)
})
})
export const flattenToolRequest = (request: LLMRequest) => {
const flatten = (tools: ReadonlyArray<ToolEntry>, path: ReadonlyArray<string>): ReadonlyArray<ToolDefinition> =>
tools.flatMap((tool) => {
if (tool.type === "namespace") return flatten(tool.tools, [...path, tool.name])
if (path.length === 0) return [tool]
return [new ToolDefinition({ ...tool, name: [...path, tool.name].join("_") })]
})
const tools = Array.from(new Map(flatten(request.tools, []).map((tool) => [tool.name, tool])).values())
const messages = request.messages.map((message) => {
const content = message.content.map((part) => {
if ((part.type !== "tool-call" && part.type !== "tool-result") || part.namespace === undefined) return part
return { ...part, name: `${part.namespace}_${part.name}`, namespace: undefined }
})
return content.every((part, index) => part === message.content[index])
? message
: new Message({ ...message, content })
})
return {
tools,
request: messages.every((message, index) => message === request.messages[index])
? request
: LLMRequest.update(request, { messages }),
}
}
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
route: string,
name: string,
+10 -1
View File
@@ -55,6 +55,7 @@ const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
})
@@ -63,6 +64,7 @@ const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
text,
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
})
@@ -85,6 +87,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
@@ -94,7 +97,12 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
}
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
LLMEvent.toolInputEnd({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
providerMetadata: tool.providerMetadata,
}),
event,
]
@@ -150,6 +158,7 @@ export const appendOrStart = <K extends StreamKey>(
const tool = {
id,
name,
namespace: current?.namespace,
input: `${current?.input ?? ""}${delta.text}`,
providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata,
+15 -3
View File
@@ -448,10 +448,22 @@ export function make<Body, Prepared, Frame, Event, State>(
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const original = applyCachePolicy(resolveRequestOptions(request))
const original = resolveRequestOptions(request)
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
const dedupe = (tools: LLMRequest["tools"]): LLMRequest["tools"] => {
const result = Array.from(
new Map(
tools.map((tool) => [
`${tool.type}:${tool.name}`,
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
]),
).values(),
)
return result.length === tools.length && result.every((tool, index) => tool === tools[index]) ? tools : result
}
const tools = dedupe(sanitized.tools)
const deduplicated = tools === sanitized.tools ? sanitized : LLMRequest.update(sanitized, { tools })
const resolved = applyCachePolicy(deduplicated)
const route = resolved.model.route
const body = yield* route.body
+19 -3
View File
@@ -146,6 +146,7 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
@@ -155,6 +156,7 @@ export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
text: Schema.String,
/** Best-effort parse of all input fragments received through this delta. */
input: Schema.optional(Schema.Unknown),
@@ -165,6 +167,7 @@ export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
@@ -174,6 +177,7 @@ export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
raw: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputError" })
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
@@ -182,6 +186,7 @@ export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -192,6 +197,7 @@ export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
result: ToolResultValue,
output: Schema.optional(ToolOutput),
providerExecuted: Schema.optional(Schema.Boolean),
@@ -203,6 +209,7 @@ export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
message: Schema.String,
error: Schema.optional(Schema.Defect()),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -373,6 +380,7 @@ interface ContentAssembly {
interface ToolInputAssembly {
readonly name: string
readonly namespace?: string
readonly text: string
readonly providerMetadata?: ProviderMetadata
}
@@ -510,12 +518,17 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
...state,
toolInputs: {
...state.toolInputs,
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
[event.id]: {
name: event.name,
namespace: event.namespace,
text: "",
providerMetadata: event.providerMetadata,
},
},
})
const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => {
const current = state.toolInputs[event.id] ?? { name: event.name, text: "" }
const current = state.toolInputs[event.id] ?? { name: event.name, namespace: event.namespace, text: "" }
return {
...state,
toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } },
@@ -523,7 +536,7 @@ const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): Resp
}
const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => {
const current = state.toolInputs[event.id] ?? { name: event.name, text: "" }
const current = state.toolInputs[event.id] ?? { name: event.name, namespace: event.namespace, text: "" }
return {
...state,
toolInputs: {
@@ -531,6 +544,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
[event.id]: {
...current,
name: event.name,
namespace: event.namespace,
providerMetadata: event.providerMetadata ?? current.providerMetadata,
},
},
@@ -541,6 +555,7 @@ const toolCallContent = (event: ToolCall): ContentPart =>
ToolCallPart.make({
id: event.id,
name: event.name,
namespace: event.namespace,
input: event.input,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }),
@@ -550,6 +565,7 @@ const toolResultContent = (event: ToolResult): ContentPart =>
ToolResultPart.make({
id: event.id,
name: event.name,
namespace: event.namespace,
result: event.result,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }),
+67 -3
View File
@@ -132,6 +132,7 @@ export const ToolCallPart = Object.assign(
type: Schema.Literal("tool-call"),
id: Schema.String,
name: Schema.String,
namespace: Schema.optional(Schema.String),
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
@@ -149,6 +150,7 @@ export const ToolResultPart = Object.assign(
type: Schema.Literal("tool-result"),
id: Schema.String,
name: Schema.String,
namespace: Schema.optional(Schema.String),
result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
@@ -165,6 +167,7 @@ export const ToolResultPart = Object.assign(
type: "tool-result",
id: input.id,
name: input.name,
namespace: input.namespace,
result: ToolResultValue.make(input.result, input.resultType),
providerExecuted: input.providerExecuted,
cache: input.cache,
@@ -231,7 +234,18 @@ export namespace Message {
make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
}
export type ToolDefinitionInput = {
readonly name: string
readonly description: string
readonly inputSchema: JsonSchema
readonly outputSchema?: JsonSchema
readonly cache?: CacheHint
readonly metadata?: Readonly<Record<string, unknown>>
readonly native?: Readonly<Record<string, unknown>>
}
export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefinition")({
type: Schema.Literal("tool"),
name: Schema.String,
description: Schema.String,
inputSchema: JsonSchema,
@@ -239,15 +253,65 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefini
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
}) {
constructor(input: ToolDefinitionInput) {
super({ ...input, type: "tool" })
}
}
export namespace ToolDefinition {
export type Input = ToolDefinition | ConstructorParameters<typeof ToolDefinition>[0]
export type Input = ToolDefinition | ToolDefinitionInput
/** Normalize tool definition input into the canonical `ToolDefinition` class. */
export const make = (input: Input) => (input instanceof ToolDefinition ? input : new ToolDefinition(input))
}
export type ToolNamespace = {
readonly type: "namespace"
readonly name: string
readonly description?: string
readonly tools: ReadonlyArray<ToolEntry>
}
export type ToolNamespaceInput = Omit<ToolNamespace, "type" | "tools"> & {
readonly tools: ReadonlyArray<ToolEntryInput>
}
export type ToolNamespaceEntryInput = ToolNamespaceInput & { readonly type: "namespace" }
export const ToolNamespace: Schema.Codec<ToolNamespace> & {
readonly make: (input: ToolNamespace | ToolNamespaceInput) => ToolNamespace
} = Object.assign(
Schema.Struct({
type: Schema.Literal("namespace"),
name: Schema.String,
description: Schema.optional(Schema.UndefinedOr(Schema.String)),
tools: Schema.Array(Schema.suspend((): Schema.Codec<ToolEntry> => ToolEntry)),
}).annotate({ identifier: "LLM.ToolNamespace" }),
{
make: (input: ToolNamespace | ToolNamespaceInput): ToolNamespace => ({
type: "namespace",
name: input.name,
description: input.description,
tools: input.tools.map((tool) => ToolEntry.make(tool)),
}),
},
)
export type ToolEntry = ToolDefinition | ToolNamespace
export type ToolEntryInput = ToolDefinition.Input | ToolNamespaceEntryInput
export const ToolEntry: Schema.Codec<ToolEntry> & {
readonly make: (input: ToolEntryInput) => ToolEntry
} = Object.assign(
Schema.Union([ToolDefinition, ToolNamespace]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "LLM.ToolEntry" }),
),
{
make: (input: ToolEntryInput): ToolEntry =>
"type" in input && input.type === "namespace" ? ToolNamespace.make(input) : ToolDefinition.make(input),
},
)
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
type: Schema.Literals(["auto", "none", "required", "tool"]),
name: Schema.optional(Schema.String),
@@ -277,7 +341,7 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
model: LanguageModelSchema,
system: Schema.Array(SystemPart),
messages: Schema.Array(Message),
tools: Schema.Array(ToolDefinition),
tools: Schema.Array(ToolEntry),
toolChoice: Schema.optional(ToolChoice),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
+12 -4
View File
@@ -37,7 +37,13 @@ function missingToolResults(calls: Iterable<ToolCallPart>) {
return new Message({
role: "tool",
content: [...calls].map((call) =>
ToolResultPart.make({ id: call.id, name: call.name, result: MISSING_TOOL_RESULT, resultType: "error" }),
ToolResultPart.make({
id: call.id,
name: call.name,
namespace: call.namespace,
result: MISSING_TOOL_RESULT,
resultType: "error",
}),
),
})
}
@@ -47,7 +53,7 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
if (part.type !== "tool-result" || part.providerExecuted === true) return part
const call = pending.get(part.id)
if (call) pending.delete(part.id)
return normalizeToolResult(part, call?.name ?? part.name)
return normalizeToolResult(part, call)
})
if (content.length === 0) return undefined
if (content.every((part, index) => part === message.content[index])) return message
@@ -60,8 +66,10 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
})
}
function normalizeToolResult(part: ToolResultPart, name: string): ToolResultPart {
const named = part.name === name ? part : { ...part, name }
function normalizeToolResult(part: ToolResultPart, call: ToolCallPart | undefined): ToolResultPart {
const name = call?.name ?? part.name
const namespace = call === undefined ? part.namespace : call.namespace
const named = part.name === name && part.namespace === namespace ? part : { ...part, name, namespace }
if (named.result.type === "text" && named.result.value === "")
return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (named.result.type === "error" && named.result.value === "")
+12 -4
View File
@@ -21,10 +21,11 @@ export interface DispatchResult extends ToolSettlement {
/** Execute one canonical tool call without owning provider IO or continuation. */
export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
const tool = tools[call.name]
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${call.name}` }))
const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}`
const tool = tools[name]
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` }))
if (!tool.execute)
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${call.name}` }))
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` }))
return decodeAndExecute(tool, call).pipe(
Effect.map((value) => result(call, value)),
@@ -38,7 +39,11 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
tool._decode(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((decoded) =>
tool.execute!(decoded, { id: call.id, name: call.name }).pipe(
tool.execute!(decoded, {
id: call.id,
name: call.name,
namespace: call.namespace,
}).pipe(
Effect.flatMap((value) =>
tool._encode(value).pipe(
Effect.mapError(
@@ -71,6 +76,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
LLMEvent.toolError({
id: call.id,
name: call.name,
namespace: call.namespace,
message: String(settlement.result.value),
error,
providerMetadata: call.providerMetadata,
@@ -78,6 +84,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
LLMEvent.toolResult({
id: call.id,
name: call.name,
namespace: call.namespace,
result: settlement.result,
providerMetadata: call.providerMetadata,
}),
@@ -86,6 +93,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
LLMEvent.toolResult({
id: call.id,
name: call.name,
namespace: call.namespace,
result: settlement.result,
output: settlement.output,
providerMetadata: call.providerMetadata,
+1
View File
@@ -16,6 +16,7 @@ export type ToolSchema<T> = Schema.Codec<T, any, never, never>
export interface ToolExecuteContext {
readonly id: ToolCallPart["id"]
readonly name: ToolCallPart["name"]
readonly namespace?: ToolCallPart["namespace"]
}
export type ToolExecute<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
+53
View File
@@ -215,6 +215,35 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("deduplicates tools before counting cache hints", () =>
Effect.gen(function* () {
const manual = new CacheHint({ type: "ephemeral" })
const duplicate = (description: string) => ({
name: "lookup",
description,
inputSchema: { type: "object" },
cache: manual,
})
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
tools: [
duplicate("first"),
duplicate("second"),
duplicate("third"),
duplicate("fourth"),
{ name: "lookup", description: "final", inputSchema: { type: "object" } },
],
cache: { tools: true },
}),
)
expect(prepared.body.tools).toEqual([
expect.objectContaining({ name: "lookup", description: "final", cache_control: { type: "ephemeral" } }),
])
}),
)
it.effect("auto policy preserves manual CacheHints on other parts", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -281,6 +310,30 @@ describe("applyCachePolicy", () => {
}),
)
test("marks the final leaf inside a tool namespace", () => {
const request = LLM.request({
model: anthropicModel,
tools: [
{
type: "namespace",
name: "crm",
tools: [
{ name: "lookup", description: "lookup", inputSchema: {} },
{ name: "orders", description: "orders", inputSchema: {} },
],
},
],
cache: { tools: true },
})
const applied = applyCachePolicy(request)
const namespace = applied.tools[0]
expect(namespace?.type).toBe("namespace")
if (namespace?.type !== "namespace") throw new Error("Expected namespace")
expect(namespace.tools[0]).not.toHaveProperty("cache")
expect(namespace.tools[1]).toHaveProperty("cache", { type: "ephemeral" })
})
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+62 -2
View File
@@ -1,8 +1,16 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolDefinition, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import {
LLM,
LLMRequest,
Message,
ToolCallPart,
ToolDefinition,
ToolNamespace,
mergeProviderOptions,
} from "../src/index.js"
import { AnthropicMessages, OpenAIChat, OpenAIResponses } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { it } from "./lib/effect.js"
@@ -106,6 +114,58 @@ describe("request option precedence", () => {
}),
)
it.effect("deduplicates tools within each namespace", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAIResponses.route.model({ id: "gpt-5.4" }),
tools: [
ToolDefinition.make({ name: "crm", description: "Top-level CRM tool", inputSchema: {} }),
ToolNamespace.make({
name: "crm",
description: "CRM tools",
tools: [
ToolDefinition.make({ name: "lookup", description: "old", inputSchema: {} }),
ToolDefinition.make({ name: "search", description: "search", inputSchema: {} }),
ToolDefinition.make({ name: "lookup", description: "new", inputSchema: {} }),
],
}),
ToolNamespace.make({
name: "support",
description: "Support tools",
tools: [ToolDefinition.make({ name: "lookup", description: "support", inputSchema: {} })],
}),
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "crm",
description: "Top-level CRM tool",
parameters: {},
strict: false,
},
{
type: "namespace",
name: "crm",
description: "CRM tools",
tools: [
{ type: "function", name: "lookup", description: "new", parameters: {}, strict: false },
{ type: "function", name: "search", description: "search", parameters: {}, strict: false },
],
},
{
type: "namespace",
name: "support",
description: "Support tools",
tools: [{ type: "function", name: "lookup", description: "support", parameters: {}, strict: false }],
},
])
}),
)
it.effect("normalizes tool history before protocol lowering", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+48 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { CacheHint, LLM, LLMResponse } from "../src/index.js"
import { Schema } from "effect"
import { CacheHint, LLM, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
import {
@@ -17,6 +18,52 @@ const chatRoute = OpenAIChat.route
const responsesRoute = OpenAIResponses.route
describe("llm constructors", () => {
test("normalizes recursive tool namespaces", () => {
const request = LLM.request({
model: LanguageModel.make({ id: "fake-model", provider: "fake", route: responsesRoute }),
tools: [
{
type: "namespace",
name: "crm",
description: "Customer management",
tools: [
{ name: "lookup", description: "Look up a customer", inputSchema: { type: "object" } },
{
type: "namespace",
name: "orders",
tools: [{ name: "list", description: "List orders", inputSchema: { type: "object" } }],
},
],
},
],
})
expect(request.tools[0]).toEqual({
type: "namespace",
name: "crm",
description: "Customer management",
tools: [
expect.objectContaining({ type: "tool", name: "lookup" }),
{
type: "namespace",
name: "orders",
description: undefined,
tools: [expect.objectContaining({ type: "tool", name: "list" })],
},
],
})
expect(request.tools[0]).toEqual(
ToolNamespace.make({
name: "crm",
description: "Customer management",
tools: request.tools[0]!.type === "namespace" ? request.tools[0].tools : [],
}),
)
expect(Schema.decodeUnknownSync(ToolEntry)(Schema.encodeUnknownSync(ToolEntry)(request.tools[0]))).toEqual(
request.tools[0],
)
})
test("builds canonical schema classes from ergonomic input", () => {
const request = LLM.request({
id: "req_1",
@@ -347,7 +347,7 @@ describe("Open Responses basic-item lifecycles", () => {
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-start", id: "call_1", name: "lookup", namespace: undefined, providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
@@ -383,11 +383,19 @@ describe("Open Responses basic-item lifecycles", () => {
type: "tool-input-delta",
id: "call_1",
name: "lookup",
namespace: undefined,
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", namespace: undefined, providerMetadata },
{
type: "tool-call",
id: "call_1",
name: "lookup",
namespace: undefined,
input: { query: "weather" },
providerMetadata,
},
])
}),
)
@@ -171,6 +171,66 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("flattens tool namespaces", () =>
Effect.gen(function* () {
const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model(
"example-model",
)
const prepared = yield* compileRequest(
LLM.request({
model,
tools: [
{
type: "namespace",
name: "acme",
tools: [
{
type: "namespace",
name: "billing",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup billing", inputSchema: {} })],
},
ToolDefinition.make({ name: "users", description: "Lookup users", inputSchema: {} }),
],
},
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "acme_billing_lookup",
description: "Lookup billing",
parameters: {},
strict: false,
},
{ type: "function", name: "acme_users", description: "Lookup users", parameters: {}, strict: false },
])
}),
)
it.effect("flattens tool namespaces in history", () =>
Effect.gen(function* () {
const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model(
"example-model",
)
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant({ type: "tool-call", id: "call_1", name: "lookup", namespace: "crm", input: {} }),
Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "crm_lookup", namespace: undefined, arguments: "{}" },
{ type: "function_call_output", call_id: "call_1", output: "done" },
])
}),
)
it.effect("lowers canonical parallel tool control", () =>
Effect.gen(function* () {
const model = configure({
@@ -12,6 +12,7 @@ import {
LanguageModel,
ToolCallPart,
ToolDefinition,
ToolNamespace,
ToolResultPart,
TransportError,
Usage,
@@ -143,6 +144,84 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers tool namespaces without flattening leaf names", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Find a customer and their orders.",
tools: [
ToolNamespace.make({
name: "crm",
description: "Customer management",
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up a customer", inputSchema: {} }),
ToolDefinition.make({ name: "orders", description: "List customer orders", inputSchema: {} }),
],
}),
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "namespace",
name: "crm",
description: "Customer management",
tools: [
{ type: "function", name: "lookup", description: "Look up a customer", parameters: {}, strict: false },
{ type: "function", name: "orders", description: "List customer orders", parameters: {}, strict: false },
],
},
])
}),
)
it.effect("flattens nested levels within a native tool namespace", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
tools: [
{
type: "namespace",
name: "crm",
description: "Customer management",
tools: [
{
type: "namespace",
name: "orders",
description: "Order management",
tools: [ToolDefinition.make({ name: "list", description: "List orders", inputSchema: {} })],
},
],
},
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "namespace",
name: "crm",
description: "Customer management",
tools: [{ type: "function", name: "orders_list", description: "List orders", parameters: {}, strict: false }],
},
])
}),
)
it.effect("requires tool namespace descriptions", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({ model, tools: [{ type: "namespace", name: "crm", tools: [] }] }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("tool namespaces require a description")
}),
)
it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -2129,6 +2208,73 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves tool namespaces through streaming and history replay", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: "",
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "fc_1",
delta: '{"id":"123"}',
},
{
type: "response.output_item.done",
output_index: 0,
item: { ...item, namespace: "crm", arguments: '{"id":"123"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
const toolEvents = response.events.filter((event) => event.type.startsWith("tool-"))
expect(toolEvents).toEqual([
expect.objectContaining({ type: "tool-input-start", name: "lookup" }),
expect.objectContaining({ type: "tool-input-delta", name: "lookup" }),
expect.objectContaining({ type: "tool-input-end", name: "lookup", namespace: "crm" }),
expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }),
])
expect(toolEvents[0]?.namespace).toBeUndefined()
expect(toolEvents[1]?.namespace).toBeUndefined()
expect(response.message.content).toEqual([
expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }),
])
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: { customer: "Ada" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "fc_1",
call_id: "call_1",
namespace: "crm",
name: "lookup",
arguments: '{"id":"123"}',
},
{ type: "function_call_output", call_id: "call_1", output: '{"customer":"Ada"}' },
])
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
+10 -7
View File
@@ -15,13 +15,7 @@ describe("tool history normalization", () => {
Message.assistant(toolCall("trailing")),
])
expect(normalized.map((message) => message.role)).toEqual([
"assistant",
"tool",
"tool",
"user",
"assistant",
])
expect(normalized.map((message) => message.role)).toEqual(["assistant", "tool", "tool", "user", "assistant"])
expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" })
expect(normalized[2]?.content).toEqual([
{ type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } },
@@ -74,4 +68,13 @@ describe("tool history normalization", () => {
expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted])
})
test("uses a matching call as the complete tool identity", () => {
const normalized = normalizeToolHistory([
Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })),
Message.tool(ToolResultPart.make({ id: "call_1", name: "wrong", namespace: "stale", result: "done" })),
])
expect(normalized[1]?.content[0]).toMatchObject({ name: "lookup", namespace: undefined })
})
})
+22
View File
@@ -7,6 +7,7 @@ import {
LLMEvent,
LLMRequest,
LLMResponse,
ToolCallPart,
ToolChoice,
ToolOutput,
toDefinitions,
@@ -36,6 +37,27 @@ const baseRequest = LLM.request({
})
const weatherFailureCause = new Error("weather lookup denied")
test("dispatches namespaced calls by qualified identity", async () => {
let context: ToolExecuteContext | undefined
const lookup = Tool.make({
description: "Look up a customer.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: (_, value) => {
context = value
return Effect.succeed("customer")
},
})
const call = ToolCallPart.make({ id: "call_1", namespace: "crm", name: "lookup", input: {} })
const result = await Effect.runPromise(
ToolRuntime.dispatch({ "crm.lookup": lookup, lookup: schema_only_weather }, call),
)
expect(result.result).toEqual({ type: "text", value: "customer" })
expect(context).toEqual({ id: "call_1", namespace: "crm", name: "lookup" })
expect(result.events).toEqual([expect.objectContaining({ type: "tool-result", namespace: "crm", name: "lookup" })])
})
const get_weather = Tool.make({
description: "Get current weather for a city.",
parameters: Schema.Struct({ city: Schema.String }),
@@ -123,7 +123,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -131,10 +131,8 @@ async function setup(page: Page) {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -144,6 +142,6 @@ async function setup(page: Page) {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
}
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -88,10 +88,8 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -101,7 +99,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -64,7 +64,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
source: { type: "package", target: id },
state: { status: "active" },
features: { server: true },
})),
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -69,10 +69,8 @@ test("opens and searches project files inline", async ({ page }) => {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
@@ -82,7 +80,7 @@ test("opens and searches project files inline", async ({ page }) => {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -22,6 +22,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
await selectFile(page, "alpha.ts")
await switchSession(page, titleB)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
@@ -18,37 +18,72 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The v2 review pane's diff data is workspace-scoped: switching between session
// tabs in the same workspace must update its parameters reactively instead of
// tearing the pane down and remounting it (which flickers).
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
// The review pane's data is workspace-scoped, but visibility belongs to each
// session tab. Switching tabs must keep the pane mounted without opening it.
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toContain("0.24s")
const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await expect(review).toBeHidden()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toBe("0s")
expect(await readProbe(page)).toBe(PROBE)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "520px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await expect
.poll(() =>
page.evaluate(
({ key }) => {
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
return panes[key]?.review
},
{ key: `${server}\n${sessionHref(sessionA)}` },
),
)
.toBe(true)
await page.reload()
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
@@ -102,7 +137,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -114,8 +149,19 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
@@ -123,12 +123,17 @@ test("uses side placement by default and supports the terminal across the bottom
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.addInitScript(
({ tabKey, server, sessionID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
@@ -85,7 +85,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
data: [
{
id: "demo-plugin",
source: { type: "package", package: "demo-plugin" },
source: { type: "package", target: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
@@ -252,8 +252,15 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs.panes",
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
localStorage.setItem(
terminalKey,
JSON.stringify({
@@ -262,7 +269,13 @@ function seedCachedTerminal(page: Page) {
}),
)
},
{ terminalKey: terminalStorageKey(), ptyID },
{
terminalKey: terminalStorageKey(),
ptyID,
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
server,
sessionID,
},
)
}
@@ -16,10 +16,9 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// Terminals are workspace-scoped: switching between session tabs in the same
// workspace must keep the terminal mounted and its PTY connection open instead
// of tearing it down and reconnecting.
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
// Terminal processes are workspace-scoped, but panel visibility belongs to each
// session tab. Switching tabs must keep the PTY alive without opening its panel.
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
const connections = await setup(page)
await page.goto(sessionHref(sessionA))
@@ -27,7 +26,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
const terminalPanel = page.locator('[data-component="terminal-panel"]')
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
await expect(terminalPanel).toHaveCSS("height", "300px")
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
@@ -37,15 +39,26 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(terminal).toBeVisible()
await expect(terminal).toBeHidden()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "180px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.reload()
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -120,7 +133,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -132,8 +145,20 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
return connections
}
@@ -75,11 +75,12 @@ for (const theme of ["light", "dark"] as const) {
await expectBackground(view.send, "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectBackground(
await expectToken(
message,
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
"background-color",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
@@ -245,3 +246,16 @@ async function expectBackground(element: Locator, token: string, property = "bac
}, token)
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
}
async function expectToken(element: Locator, property: string, token: string) {
const color = await element.evaluate((element, token) => {
const probe = document.createElement("span")
probe.hidden = true
probe.style.color = `var(${token})`
element.append(probe)
const color = getComputedStyle(probe).color
probe.remove()
return color
}, token)
await expect(element).toHaveCSS(property, color)
}
@@ -8,7 +8,7 @@ describe("pluginLabels", () => {
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", package: "example" },
source: { type: "package", target: "example" },
state: { status: "active" },
features: { server: true },
},
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+19 -10
View File
@@ -1,16 +1,25 @@
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
const current = value()
const show = current !== undefined
return {
show,
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
export function createAnimatedPresence<T>(
value: Accessor<T | undefined>,
element: Accessor<HTMLElement | null>,
identity?: Accessor<unknown>,
) {
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
(previous) => {
const currentIdentity = identity?.()
const current = value()
const show = current !== undefined
const same = !identity || previous?.identity === currentIdentity
return {
identity: currentIdentity,
show,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
},
)
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
@@ -155,6 +155,16 @@ describe("isSessionNotFoundError", () => {
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true)
})
test("matches a structured error stored directly as the cause", () => {
const body = {
_tag: "SessionNotFoundError",
sessionID: "ses_missing",
message: "Session not found",
} satisfies SessionNotFoundError
expect(isSessionNotFoundError(new Error("Unknown error", { cause: body }), body.sessionID)).toBe(true)
})
test("rejects errors for other sessions and other 404 responses", () => {
const body = {
_tag: "SessionNotFoundError",
+3 -4
View File
@@ -36,10 +36,9 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
}
function unwrapNamedError(error: unknown): unknown {
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
return (error.cause as Record<string, unknown>).body
}
return error
if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") return error
if ("body" in error.cause) return (error.cause as Record<string, unknown>).body
return error.cause
}
// Client-synthesized session not-found errors share one constructor and
+1
View File
@@ -129,6 +129,7 @@ export function useSessionModel() {
layout: {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
+3 -2
View File
@@ -12,6 +12,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
const view = session.layout.view
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
@@ -42,7 +43,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split")
const resizedWidth = createMemo(() =>
clampSessionPanelWidth({
width: layout.session.width(),
width: view().reviewPanel.width(),
available: available(),
split: splitReview(),
}),
@@ -72,7 +73,7 @@ export function createSessionScreenLayout(session: SessionModel) {
}, panelLayout().stacked)
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
const terminalPane = createMemo(() =>
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
Math.min(view().terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
)
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
const sideHeight = createMemo(() => rowSize.height)
+57 -26
View File
@@ -11,10 +11,8 @@ import {
on,
} from "solid-js"
import { createStore } from "solid-js/store"
import createPresence from "solid-presence"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/shell/state/layout"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
@@ -31,6 +29,7 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { createAnimatedPresence } from "@/runtime/animated-presence"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -39,7 +38,6 @@ const SessionMobileFiles = lazy(async () => {
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
@@ -66,14 +64,40 @@ export function SessionScreen(props: { session: SessionModel }) {
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
const sidePresence = createPresence({
show: sideVisible,
element: () => elements.side ?? null,
})
const bottomTerminalPresence = createPresence({
show: bottomTerminalVisible,
element: () => elements.bottomTerminal ?? null,
const sidePresence = createAnimatedPresence(
() => sideVisible() || undefined,
() => elements.side ?? null,
session.layout.tabKey,
)
const bottomTerminalPresence = createAnimatedPresence(
() => bottomTerminalVisible() || undefined,
() => elements.bottomTerminal ?? null,
session.layout.tabKey,
)
const sideMotion = createMemo<{
key?: string
region: boolean
terminal: boolean
animateRegion: boolean
animateTerminal: boolean
}>((previous) => {
const key = session.layout.tabKey()
const region = screen.side.region.open()
const terminal = sideTerminalVisible()
const sameTab = previous?.key === key
return {
key,
region,
terminal,
animateRegion: !!previous && sameTab && previous.region !== region,
animateTerminal: !!previous && sameTab && previous.terminal !== terminal,
}
})
const paneAnimating = () =>
sidePresence.animate() ||
sideMotion().animateRegion ||
sideMotion().animateTerminal ||
bottomTerminalPresence.animate()
createEffect(() => {
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
@@ -256,7 +280,8 @@ export function SessionScreen(props: { session: SessionModel }) {
classList={{
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active(),
!screen.size.active() && sidePresence.animate(),
"transition-none": screen.size.active() || !sidePresence.animate(),
}}
data-slot="session-chat-panel"
style={{
@@ -279,18 +304,18 @@ export function SessionScreen(props: { session: SessionModel }) {
max={screen.panel.max()}
onResize={(width) => {
screen.size.touch()
layout.session.resize(width)
session.layout.view().reviewPanel.resize(width)
}}
/>
</div>
</Show>
</div>
<Show when={sidePresence.present() || store.sideTerminalPresent}>
<Show when={sidePresence.present() || store.sideReviewPresent || store.sideTerminalPresent}>
<div
ref={(element) => setElements("side", element)}
data-slot="session-side-panel-presence"
data-opened={sideVisible()}
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
@@ -311,15 +336,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-region"
classList={{
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.region.height() }}
>
<Show when={store.sideRegionPresent}>
<div
data-slot="session-side-region-presence"
data-opened={screen.side.region.open()}
data-opened={sideMotion().animateRegion ? sideMotion().region : undefined}
class="absolute inset-0"
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
@@ -341,6 +366,7 @@ export function SessionScreen(props: { session: SessionModel }) {
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"delay-0": !screen.side.gap.closing(),
"delay-[200ms]": screen.side.gap.closing(),
"transition-none": !paneAnimating(),
}}
style={{ height: screen.side.gap.height() }}
onPointerDown={() => screen.size.start()}
@@ -349,13 +375,13 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
@@ -365,15 +391,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-terminal-region"
classList={{
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.terminal.height() }}
>
<Show when={store.sideTerminalPresent}>
<div
data-slot="side-terminal-panel-presence"
data-opened={sideTerminalVisible()}
data-opened={sideMotion().animateTerminal ? sideMotion().terminal : undefined}
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
@@ -381,6 +407,7 @@ export function SessionScreen(props: { session: SessionModel }) {
fill
framed={false}
present={store.sideTerminalPresent}
animate={sidePresence.animate() || sideMotion().animateTerminal}
contentHeight={screen.side.terminal.contentHeight()}
/>
</div>
@@ -397,7 +424,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<div
ref={(element) => setElements("bottomTerminal", element)}
data-slot="terminal-panel-presence"
data-opened={bottomTerminalVisible()}
data-opened={bottomTerminalPresence.animate() ? bottomTerminalPresence.show() : undefined}
classList={{
hidden: !bottomTerminalPresence.present(),
"relative min-h-0 shrink-0": isDesktop(),
@@ -408,19 +435,23 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</div>
</Show>
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
<TerminalPanel
stacked={isDesktop()}
present={store.bottomTerminalCached}
animate={bottomTerminalPresence.animate()}
/>
</div>
</Show>
</div>
+23 -1
View File
@@ -5,6 +5,8 @@ import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { ServerConnection } from "@/runtime/server/registry"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
export const useSessionKey = () => {
const params = useParams()
@@ -19,12 +21,32 @@ export const useSessionKey = () => {
export const useSessionLayout = () => {
const layout = useLayout()
const tabs = useTabs()
const { params, sessionKey, workspaceKey } = useSessionKey()
const serverSDK = useServerSDK()
const currentTab = createMemo(() => {
if (!params.id) return
return findSessionTab(tabs.store, ServerConnection.key(serverSDK.server), params.id)
})
const panes = {
terminalOpened: () => tabs.pane(currentTab(), "terminal"),
setTerminalOpened: (opened: boolean) => tabs.setPane(currentTab(), "terminal", opened),
terminalHeight: () => tabs.paneSize(currentTab(), "terminalHeight"),
setTerminalHeight: (height: number) => tabs.setPaneSize(currentTab(), "terminalHeight", height),
reviewOpened: () => tabs.pane(currentTab(), "review"),
setReviewOpened: (opened: boolean) => tabs.setPane(currentTab(), "review", opened),
sessionWidth: () => tabs.paneSize(currentTab(), "sessionWidth"),
setSessionWidth: (width: number) => tabs.setPaneSize(currentTab(), "sessionWidth", width),
}
return {
params,
sessionKey,
workspaceKey,
tabKey: createMemo(() => {
const tab = currentTab()
return tab && tabKey(tab)
}),
tabs: createMemo(() => layout.tabs(sessionKey)),
view: createMemo(() => layout.view(sessionKey)),
view: createMemo(() => layout.view(sessionKey, panes)),
}
}
+4 -4
View File
@@ -17,7 +17,6 @@ import { SortableTerminalTab } from "@/session/terminal/tab"
import { Terminal } from "@/session/terminal/terminal"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
import { useWorkspaceLocation } from "@/workspaces/location"
import { terminalTabLabel } from "@/session/terminal/terminal-label"
@@ -45,9 +44,9 @@ export function TerminalPanel(
present?: boolean
contentHeight?: string
embedded?: boolean
animate?: boolean
} = {},
) {
const layout = useLayout()
const terminal = useTerminal()
const sdk = useWorkspaceLocation()
const language = useLanguage()
@@ -57,7 +56,7 @@ export function TerminalPanel(
const isDesktop = createMediaQuery("(min-width: 768px)")
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const height = createMemo(() => view().terminal.height())
const close = () => view().terminal.close()
let root: HTMLElement | undefined
let tabList: HTMLDivElement | undefined
@@ -238,10 +237,11 @@ export function TerminalPanel(
pane={pane()}
max={max()}
resizing={size.active()}
animate={props.animate}
onResizeStart={size.start}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
view().terminal.resize(next)
}}
onCollapse={close}
>
@@ -15,6 +15,7 @@ export function TerminalSurface(
pane: number
max: number
resizing: boolean
animate?: boolean
onResizeStart: () => void
onResize: (height: number) => void
onCollapse: () => void
@@ -27,7 +28,9 @@ export function TerminalSurface(
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
data-size-animated={
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
@@ -478,7 +478,11 @@ export function createTimelineVirtualizer(input: Input) {
}
return (
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div
class="relative w-full h-full min-w-0"
data-workspace-session={props.workspaceSession() ? "" : undefined}
data-local-session={!props.workspaceSession() ? "" : undefined}
>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
+49 -3
View File
@@ -59,6 +59,16 @@ export type HomeProjectSelection = { server: ServerConnection.Key; directory?: s
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
terminalHeight: Accessor<number | undefined>
setTerminalHeight(height: number): void
reviewOpened: Accessor<boolean>
setReviewOpened(opened: boolean): void
sessionWidth: Accessor<number | undefined>
setSessionWidth(width: number): void
}
export type LayoutRoute =
| { type: "home" }
@@ -508,7 +518,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return message
},
},
view(sessionKey: string | Accessor<string>) {
view(sessionKey: string | Accessor<string>, panes?: TabPanes) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
@@ -519,11 +529,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const terminalOpened = panes?.terminalOpened ?? createMemo(() => store.terminal?.opened ?? false)
const terminalHeight = createMemo(() =>
panes
? (panes.terminalHeight() ?? DEFAULT_TERMINAL_HEIGHT)
: (store.terminal?.height ?? DEFAULT_TERMINAL_HEIGHT),
)
const reviewPanelOpened =
panes?.reviewOpened ?? createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const sessionWidth = createMemo(() =>
panes ? (panes.sessionWidth() ?? DEFAULT_SESSION_WIDTH) : store.session.width,
)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) {
if (panes) {
panes.setTerminalOpened(next)
return
}
const current = store.terminal
if (!current) {
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
@@ -537,6 +560,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
const nextSource = next ? source : "other"
if (panes) {
batch(() => {
panes.setReviewOpened(next)
setEphemeral("reviewPanelSource", nextSource)
})
return
}
const current = store.review
if (!current) {
batch(() => {
@@ -566,6 +596,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
terminal: {
opened: terminalOpened,
height: terminalHeight,
resize(height: number) {
if (panes) {
panes.setTerminalHeight(height)
return
}
setStore("terminal", "height", height)
},
open() {
setTerminalOpened(true)
},
@@ -579,6 +617,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
reviewPanel: {
opened: reviewPanelOpened,
source: reviewPanelSource,
width: sessionWidth,
resize(width: number) {
if (panes) {
panes.setSessionWidth(width)
return
}
setStore("session", "width", width)
},
open(source: ReviewPanelSource = "other") {
setReviewPanelOpened(true, source)
},
+6 -2
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -48,8 +48,12 @@ test("session tab identity stays rooted while its href follows the child route",
})
test("finds open root and routed session tabs", () => {
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
const tab = { ...sessionTab("root"), routeSessionId: "child" }
const tabs = [tab]
expect(findSessionTab(tabs, server, "root")).toBe(tab)
expect(findSessionTab(tabs, server, "child")).toBe(tab)
expect(findSessionTab(tabs, server, "closed")).toBeUndefined()
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
+60 -3
View File
@@ -46,6 +46,10 @@ export type TabInfo = {
directory?: string
}
export type TabPane = "terminal" | "review"
export type TabPaneSize = "terminalHeight" | "sessionWidth"
type TabPaneState = Partial<Record<TabPane, boolean> & Record<TabPaneSize, number>>
type RecentTab = {
key?: string
}
@@ -62,8 +66,8 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses
return sessionIDHasOpenTab(tabs, server, session.id)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.find(
(tab) =>
tab.type === "session" &&
tab.server === server &&
@@ -71,6 +75,10 @@ export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, s
)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return !!findSessionTab(tabs, server, sessionID)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
@@ -89,6 +97,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
Persist.window("tabs.info"),
createStore<Record<string, TabInfo>>({}),
)
const [panes, setPanes, , panesReady] = persisted(
Persist.window("tabs.panes"),
createStore<Record<string, TabPaneState>>({}),
)
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
@@ -141,6 +153,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
}
const removePanes = (key: string) => {
if (!panes[key]) return
setPanes(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
@@ -153,6 +174,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
removePanes(key)
}
}
setStore(() => next)
@@ -162,6 +184,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of Object.keys(info)) {
if (!keys.has(key)) removeInfo(key)
}
if (!panesReady()) return
for (const key of Object.keys(panes)) {
if (!keys.has(key)) removePanes(key)
}
})
createEffect(() => {
@@ -198,6 +224,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
removePanes(key)
if (draftID) removeDraftPersisted(draftID)
}
@@ -491,8 +518,38 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
pane(tab: Tab | undefined, pane: TabPane) {
if (!tab) return false
return panes[tabKey(tab)]?.[pane] ?? false
},
setPane(tab: Tab | undefined, pane: TabPane, opened: boolean) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[pane] === opened) return
if (!current) {
setPanes(key, { [pane]: opened })
return
}
setPanes(key, pane, opened)
},
paneSize(tab: Tab | undefined, size: TabPaneSize) {
if (!tab) return
return panes[tabKey(tab)]?.[size]
},
setPaneSize(tab: Tab | undefined, size: TabPaneSize, value: number) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[size] === value) return
if (!current) {
setPanes(key, { [size]: value })
return
}
setPanes(key, size, value)
},
}
return { ...actions, store, info, ready, infoReady, recentReady }
return { ...actions, store, info, ready, infoReady, recentReady, panesReady }
},
})
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createAnimatedPresence } from "../src/runtime/animated-presence"
import { createRoot, createSignal } from "solid-js"
import { batch, createRoot, createSignal } from "solid-js"
test("animates visibility changes without animating initial presence", () => {
createRoot((dispose) => {
@@ -47,3 +47,22 @@ test("animates the first appearance when initially hidden", () => {
dispose()
})
})
test("does not animate visibility changes across identities", () => {
createRoot((dispose) => {
const [identity, setIdentity] = createSignal("a")
const [value, setValue] = createSignal<string | undefined>("visible")
const presence = createAnimatedPresence(value, () => null, identity)
expect(presence.animate()).toBe(false)
batch(() => {
setIdentity("b")
setValue(undefined)
})
expect(presence.animate()).toBe(false)
setValue("visible")
expect(presence.animate()).toBe(true)
dispose()
})
})
@@ -54,7 +54,7 @@ export function format(
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
? [{ target: plugin.source.target, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
@@ -73,7 +73,7 @@ export function format(
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+2 -2
View File
@@ -9,12 +9,12 @@ test("formats server and TUI plugins in sections without builtins", () => {
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", package: "acme-plugin@1.0.0" },
source: { type: "package", target: "acme-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
{
source: { type: "package", package: "broken-plugin" },
source: { type: "package", target: "broken-plugin" },
state: { status: "failed", error: "broken" },
features: { server: true },
},
+7 -1
View File
@@ -198,7 +198,13 @@ test("Node distribution honors the compile-time CLI name", async () => {
"--test-name-pattern",
"^Node distribution resolves the published npm package$",
],
{ cwd: path.join(import.meta.dir, ".."), stdout: "ignore", stderr: "pipe" },
{
cwd: path.join(import.meta.dir, ".."),
stdout: "ignore",
stderr: "pipe",
// Bun 1.4 can reuse cached modules compiled with different --define values.
env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0" },
},
)
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
expect(code, stderr).toBe(0)
+8
View File
@@ -88,8 +88,16 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginUpdateOutput = void
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly update: PluginUpdateOperation<E>
}
export type SessionListInput = {
+13 -1
View File
@@ -15,6 +15,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -322,7 +324,17 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
update: EndpointPluginUpdate(raw),
})
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,6 +9,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -466,6 +468,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+18 -9
View File
@@ -12,7 +12,7 @@ export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginSource =
| { type: "builtin" }
| { type: "package"; package: string }
| { type: "package"; target: string; version?: string; outdated?: true }
| { type: "local"; path: string }
| { type: "sdk" }
@@ -2354,6 +2354,14 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2415,14 +2423,6 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
@@ -2582,6 +2582,15 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginUpdateOutput = void
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
+36 -11
View File
@@ -78,16 +78,43 @@ const docTags = (schema: JsonSchema): Array<string> => {
return tags
}
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
)
const docLines = (schema: JsonSchema, width: number): Array<string> => {
const summary = docTags(schema).join(" ")
const lines = (schema.description ?? "").split("\n").map((line) => line.replace(/\s+$/, ""))
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
const inline = lines.length === 1 ? `${lines[0]}${lines[0].endsWith(".") ? "" : "."} ${summary}` : summary
return summary && lines.length === 1 && !summary.includes("\n") && width + inline.length + 7 <= 120
? [inline]
: [...lines, ...(summary ? summary.split("\n") : [])]
}
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
const jsdoc = (schema: JsonSchema, pad: string): string => {
const content = docLines(schema, pad.length)
const types = typeof schema.type === "string" ? [schema.type] : (schema.type ?? [])
const append = (label: string, child: JsonSchema) => {
docLines(child, pad.length + label.length + 2).forEach((line, index) => {
if (index === 0) {
content.push(`${label}: ${line}`)
return
}
content.push(line ? ` ${line}` : "")
})
}
// Document only the immediate contents; recursive labels obscure which level a constraint belongs to.
if (types.includes("array") && schema.items) append("Each item", schema.items)
if ((types.includes("object") || schema.properties) && typeof schema.additionalProperties === "object") {
const label = Object.keys(schema.properties ?? {}).length > 0 ? "Each additional value" : "Each value"
append(label, schema.additionalProperties)
}
if (content.length === 0) return ""
const escaped = content.map((line) => line.replaceAll("*/", "* /"))
if (escaped.length === 1 && pad.length + escaped[0].length + 7 <= 120) return `${pad}/** ${escaped[0]} */\n`
const body = escaped.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
@@ -166,9 +193,7 @@ const renderSchema = (
if (properties.length === 0 && indexType === undefined) return "{}"
const pad = " ".repeat(depth + 1)
const lines = properties.map(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
)
const lines = properties.map((entry) => `${jsdoc(entry[1], pad)}${pad}${field(entry)},`)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
+292 -65
View File
@@ -36,7 +36,7 @@ const lookupOrder = Tool.make({
})
describe("pretty signature rendering", () => {
test("described fields get JSDoc comments; undescribed and untagged fields get none", () => {
test("described fields get compact JSDoc; undescribed and unconstrained fields get none", () => {
expect(inputTypeScript(listIssues, true)).toBe(
[
"{",
@@ -44,16 +44,9 @@ describe("pretty signature rendering", () => {
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string,",
" /**",
" * Results per page",
" * @default 30",
" */",
" /** Results per page. @default 30 */",
" perPage?: number,",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" /** Filter by labels. @minItems 1 @maxItems 10 */",
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}",
@@ -105,7 +98,7 @@ describe("pretty signature rendering", () => {
)
})
test("constraints TypeScript cannot express surface as JSDoc tags", () => {
test("constraints and annotations share compact tagged JSDoc", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
@@ -119,19 +112,10 @@ describe("pretty signature rendering", () => {
)
expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
expect(pretty).toContain(" /** @format uri */\n homepage?: string")
expect(pretty).toContain(
[
" /**",
' * @default ["a","b"]',
" * @minItems 2",
" * @maxItems 5",
" */",
" tags?: Array<string>",
].join("\n"),
)
expect(pretty).toContain(' /** @default ["a","b"] @minItems 2 @maxItems 5 */\n tags?: Array<string>')
})
test("skips an unserializable default rather than emitting a broken tag", () => {
test("skips an unserializable default rather than emitting a broken summary", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { size: { type: "number", default: 1n } } },
true,
@@ -139,6 +123,175 @@ describe("pretty signature rendering", () => {
expect(pretty).toBe(["{", " size?: number,", "}"].join("\n"))
})
test("labels immediate item and dictionary value metadata above the field", () => {
const schema = {
properties: {
recipients: {
type: "array",
description: "People to notify",
minItems: 1,
items: { type: "string", description: "Email address", format: "email" },
},
scores: { type: "object", additionalProperties: { type: "number", minimum: 0 } },
names: { type: "array", items: { type: "string", description: "Display name" } },
plain: { type: "object", additionalProperties: { type: "string" } },
},
}
expect(jsonSchemaToTypeScript(schema, true)).toBe(
[
"{",
" /**",
" * People to notify. @minItems 1",
" * Each item: Email address. @format email",
" */",
" recipients?: Array<string>,",
" /** Each value: @minimum 0 */",
" scores?: {",
" [key: string]: number,",
" },",
" /** Each item: Display name */",
" names?: Array<string>,",
" plain?: {",
" [key: string]: string,",
" },",
"}",
].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe(
"{ recipients?: Array<string>; scores?: { [key: string]: number }; names?: Array<string>; plain?: { [key: string]: string } }",
)
})
test("does not apply additional property metadata to named properties", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
scores: {
type: "object",
properties: { title: { type: "string", description: "Score title" } },
additionalProperties: { type: "number", minimum: 0 },
},
},
},
true,
),
).toBe(
[
"{",
" /** Each additional value: @minimum 0 */",
" scores?: {",
" /** Score title */",
" title?: string,",
" [key: string]: number,",
" },",
"}",
].join("\n"),
)
})
test("labels array and dictionary contents in type-array unions", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
recipients: { type: ["array", "null"], items: { type: "string", format: "email" } },
scores: { type: ["object", "null"], additionalProperties: { type: "number", minimum: 0 } },
either: {
type: ["array", "object"],
items: { type: "string", minLength: 1 },
additionalProperties: { type: "number", minimum: 0 },
},
},
},
true,
),
).toBe(
[
"{",
" /** Each item: @format email */",
" recipients?: Array<string> | null,",
" /** Each value: @minimum 0 */",
" scores?: {",
" [key: string]: number,",
" } | null,",
" /**",
" * Each item: @minLength 1",
" * Each value: @minimum 0",
" */",
" either?: Array<string> | {",
" [key: string]: number,",
" },",
"}",
].join("\n"),
)
})
test("keeps multiline item metadata indented under its label and escapes terminators", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
values: {
type: "array",
minItems: 1,
items: { type: "string", description: "\nFirst */ line\n\nSecond line\n", pattern: "^\\d+\n*/$" },
},
},
},
true,
),
).toBe(
[
"{",
" /**",
" * @minItems 1",
" * Each item: First * / line",
" *",
" * Second line",
" * @pattern ^\\d+",
" * * /$",
" */",
" values?: Array<string>,",
"}",
].join("\n"),
)
})
test("does not flatten nested containers, reference targets, or branches into item metadata", () => {
expect(
jsonSchemaToTypeScript(
{
$defs: { Email: { type: "string", format: "email" } },
properties: {
matrix: { type: "array", items: { type: "array", minItems: 2, items: { type: "integer", minimum: 0 } } },
refs: { type: "array", items: { $ref: "#/$defs/Email", description: "Recipient" } },
choices: {
type: "array",
items: {
anyOf: [
{ type: "string", minLength: 1 },
{ type: "number", minimum: 0 },
],
},
},
},
},
true,
),
).toBe(
[
"{",
" /** Each item: @minItems 2 */",
" matrix?: Array<Array<number>>,",
" /** Each item: Recipient */",
" refs?: Array<string>,",
" choices?: Array<string | number>,",
"}",
].join("\n"),
)
})
test.each([
[{ type: "number", minimum: 0 }, "@minimum 0", "number"],
[{ type: "number", maximum: 0 }, "@maximum 0", "number"],
@@ -151,9 +304,18 @@ describe("pretty signature rendering", () => {
[{ type: "array", minItems: 0 }, "@minItems 0", "Array<unknown>"],
[{ type: "array", maxItems: 0 }, "@maxItems 0", "Array<unknown>"],
[{ type: "array", uniqueItems: true }, "@uniqueItems true", "Array<unknown>"],
] as const)("renders constraint %j without changing the compact type", (value, tag, type) => {
[{ type: "string", minLength: 0, maxLength: 0 }, "@minLength 0 @maxLength 0", "string"],
[{ type: "array", minItems: 0, maxItems: 0 }, "@minItems 0 @maxItems 0", "Array<unknown>"],
[
{ type: "array", minItems: 1, maxItems: 10, uniqueItems: true },
"@minItems 1 @maxItems 10 @uniqueItems true",
"Array<unknown>",
],
] as const)("renders constraint %j without changing the compact type", (value, summary, type) => {
const schema = { type: "object", properties: { value } }
expect(jsonSchemaToTypeScript(schema, true)).toBe(["{", ` /** ${tag} */`, ` value?: ${type},`, "}"].join("\n"))
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", ` /** ${summary} */`, ` value?: ${type},`, "}"].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe(`{ value?: ${type} }`)
})
@@ -188,30 +350,19 @@ describe("pretty signature rendering", () => {
)
})
test.each([false, null, ""])("preserves default %j alongside constraint tags", (value) => {
test.each([false, null, ""])("preserves default %j alongside constraints", (value) => {
expect(jsonSchemaToTypeScript({ properties: { value: { default: value, minLength: 0 } } }, true)).toContain(
` * @default ${JSON.stringify(value)}\n * @minLength 0\n`,
` /** @default ${JSON.stringify(value)} @minLength 0 */\n`,
)
})
test("escapes comment terminators in tag values", () => {
test("escapes comment terminators in summary values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", default: "*/", format: "*/", pattern: "^a*/b$" } } },
true,
),
).toBe(
[
"{",
" /**",
' * @default "* /"',
" * @format * /",
" * @pattern ^a* /b$",
" */",
" value?: string,",
"}",
].join("\n"),
)
).toBe(["{", ' /** @default "* /" @format * / @pattern ^a* /b$ */', " value?: string,", "}"].join("\n"))
})
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
@@ -236,6 +387,88 @@ describe("pretty signature rendering", () => {
)
})
test("preserves inclusive and exclusive numeric bounds together", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
value: {
type: "integer",
minimum: -10,
maximum: 10,
exclusiveMinimum: -5,
exclusiveMaximum: 5,
multipleOf: 2,
},
},
},
true,
),
).toContain(
" /** @integer @minimum -10 @maximum 10 @exclusiveMinimum -5 @exclusiveMaximum 5 @multipleOf 2 */\n value?: number,",
)
})
test.each([
["Maximum attempts", "Maximum attempts."],
["Maximum attempts.", "Maximum attempts."],
["Maximum attempts!", "Maximum attempts!."],
])("combines a short description (%s) with its summary", (description, expected) => {
expect(
jsonSchemaToTypeScript(
{ properties: { attempts: { description, type: "integer", minimum: 1, default: 3 } } },
true,
),
).toContain(` /** ${expected} @default 3 @integer @minimum 1 */\n`)
})
test("keeps multiline descriptions intact and appends a compact summary", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
attempts: {
description: "\nMaximum attempts\n\nIncludes the initial request.\n",
type: "integer",
minimum: 1,
},
},
},
true,
),
).toBe(
[
"{",
" /**",
" * Maximum attempts",
" *",
" * Includes the initial request.",
" * @integer @minimum 1",
" */",
" attempts?: number,",
"}",
].join("\n"),
)
})
test("uses a block for long descriptions without truncating or rewriting them", () => {
const description = "A detailed description. ".repeat(8).trim()
expect(jsonSchemaToTypeScript({ properties: { name: { type: "string", description, minLength: 1 } } }, true)).toBe(
["{", " /**", ` * ${description}`, " * @minLength 1", " */", " name?: string,", "}"].join("\n"),
)
})
test("preserves pattern backslashes and prefixes every line of multiline summary values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", pattern: "^\\d+\n*/$", default: "a\nb" } } },
true,
),
).toBe(
["{", " /**", ' * @default "a\\nb" @pattern ^\\d+', " * * /$", " */", " value?: string,", "}"].join("\n"),
)
})
test("stays total on cyclic $refs and pathological nesting in both modes", () => {
const cyclic = {
$ref: "#/$defs/Node",
@@ -460,9 +693,10 @@ describe("JSDoc signatures in catalogs and search results", () => {
properties: {
count: { type: "integer", minimum: 0, maximum: 10 },
name: { type: "string", minLength: 1, maxLength: 20, pattern: "^[a-z]+$" },
labels: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 },
labels: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1, maxItems: 5 },
scores: { type: "object", additionalProperties: { type: "integer", minimum: 0 } },
},
required: ["count", "name", "labels"],
required: ["count", "name", "labels", "scores"],
},
},
{
@@ -470,7 +704,11 @@ describe("JSDoc signatures in catalogs and search results", () => {
schema: Schema.Struct({
count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(10)),
name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(20), Schema.isPattern(/^[a-z]+$/)),
labels: Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(5)),
labels: Schema.Array(Schema.String.check(Schema.isMinLength(1))).check(
Schema.isMinLength(1),
Schema.isMaxLength(5),
),
scores: Schema.Record(Schema.String, Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
}),
},
])("$source constraints survive input/output catalog and search signatures", async ({ schema }) => {
@@ -480,29 +718,25 @@ describe("JSDoc signatures in catalogs and search results", () => {
description: "Constrained tool",
input: schema,
output: schema,
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"] }),
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"], scores: { test: 1 } }),
}),
},
})
const type = [
"{",
" /**",
" * @integer",
" * @minimum 0",
" * @maximum 10",
" */",
" /** @integer @minimum 0 @maximum 10 */",
" count: number,",
" /**",
" * @minLength 1",
" * @maxLength 20",
" * @pattern ^[a-z]+$",
" */",
" /** @minLength 1 @maxLength 20 @pattern ^[a-z]+$ */",
" name: string,",
" /**",
" * @minItems 1",
" * @maxItems 5",
" * @minItems 1 @maxItems 5",
" * Each item: @minLength 1",
" */",
" labels: Array<string>,",
" /** Each value: @integer @minimum 0 */",
" scores: {",
" [key: string]: number,",
" },",
"}",
].join("\n")
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
@@ -522,7 +756,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
}
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and summaries", async () => {
const { items } = await search("list issues repository")
const item = items.find(({ path }) => path === "tools.github.list_issues")!
expect(item.signature).toBe(
@@ -532,16 +766,9 @@ describe("JSDoc signatures in catalogs and search results", () => {
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string,",
" /**",
" * Results per page",
" * @default 30",
" */",
" /** Results per page. @default 30 */",
" perPage?: number,",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" /** Filter by labels. @minItems 1 @maxItems 10 */",
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}): Promise<unknown>",
+6 -2
View File
@@ -327,7 +327,10 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
},
body: {
schema: Schema.Unknown,
from: (request) => Effect.succeed(callOptions(request, packageName, info.modelID ?? info.id, optionKey)),
from: Effect.fn("AISDK.fromRequest")(function* (request) {
const flattened = ProviderShared.flattenToolRequest(request)
return callOptions(flattened.request, packageName, info.modelID ?? info.id, optionKey, flattened.tools)
}),
},
with: () => route,
model: (input) =>
@@ -406,6 +409,7 @@ function callOptions(
packageName: string | undefined,
modelID: ID,
optionKey: string,
tools: ReadonlyArray<ToolDefinition>,
): LanguageModelV3CallOptions {
return {
prompt: prompt(request),
@@ -417,7 +421,7 @@ function callOptions(
presencePenalty: request.generation?.presencePenalty,
frequencyPenalty: request.generation?.frequencyPenalty,
seed: request.generation?.seed,
tools: request.tools.map(tool),
tools: tools.map(tool),
toolChoice: toolChoice(request.toolChoice),
headers: request.http?.headers,
providerOptions: requestProviderOptions(request.providerOptions, packageName, modelID, optionKey),
+37 -7
View File
@@ -1,19 +1,25 @@
export * as CodeModeCatalog from "./catalog.js"
import type { Namespace } from "@opencode-ai/schema/tool"
import { Schema } from "effect"
export const Tool = Schema.Struct({
path: Schema.String,
type: Schema.Literal("tool"),
name: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Tool = typeof Tool.Type
export type Namespace = {
readonly type: "namespace"
readonly name: string
readonly description?: string
readonly tools: ReadonlyArray<Tool | Namespace>
}
export type Inventory = {
readonly tools: ReadonlyArray<Tool>
readonly namespaces?: ReadonlyMap<string, Namespace>
readonly tools: ReadonlyArray<Tool | Namespace>
}
const Listing = Schema.Struct({
@@ -47,14 +53,15 @@ const INLINE_BUDGET = 2_000
// considering shorter listings first until the inline budget is exhausted.
export function summarize(inventory: Inventory, options: Options = {}): Summary {
const budget = options.budget ?? INLINE_BUDGET
const namespaces = [...Map.groupBy(inventory.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)]
const flattened = flatten(inventory.tools)
const namespaces = [...Map.groupBy(flattened.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)]
.sort(([left], [right]) => {
if (left < right) return -1
if (left > right) return 1
return 0
})
.map(([name, namespaceEntries]) => {
const description = inventory.namespaces?.get(name)?.description
const description = flattened.namespaces.get(name)?.description
const listings = namespaceEntries
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
@@ -126,12 +133,35 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
}))
return {
total: inventory.tools.length,
total: flattened.tools.length,
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
namespaces: namespaceSummaries,
}
}
function flatten(entries: ReadonlyArray<Tool | Namespace>, path: ReadonlyArray<string> = []) {
const tools: Array<Omit<Tool, "name"> & { readonly path: string }> = []
const namespaces = new Map<string, Namespace>()
for (const entry of entries) {
if (entry.type === "tool") {
tools.push({
type: "tool",
path: [...path, entry.name].join("."),
description: entry.description,
signature: entry.signature,
...(entry.pinned === undefined ? {} : { pinned: entry.pinned }),
})
continue
}
const next = [...path, entry.name]
namespaces.set(next.join("."), entry)
const nested = flatten(entry.tools, next)
tools.push(...nested.tools)
for (const [name, namespace] of nested.namespaces) namespaces.set(name, namespace)
}
return { tools, namespaces }
}
export function namespaceLine(namespace: typeof NamespaceSummary.Type) {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
+40 -9
View File
@@ -40,12 +40,14 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
type ToolNode = {
tool?: Tool.Tool<never>
type Node<T> = {
tool?: T
namespace?: ToolNamespace
readonly children: Map<string, ToolNode>
readonly children: Map<string, Node<T>>
}
type ToolNode = Node<Tool.Tool<never>>
type Tools = {
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
}
@@ -162,14 +164,43 @@ export const catalog = (inventory: Inventory) => {
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
const root: CatalogNode = { children: new Map() }
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
for (const tool of runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))).catalog())
getNode(root, tool.path).tool = {
type: "tool",
name: tool.path.split(".").at(-1) ?? tool.path,
description: tool.description,
signature: tool.signature,
pinned: pinned.has(tool.path),
}
return {
tools: runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((tool) => ({ ...tool, pinned: pinned.has(tool.path) })),
...(inventory.namespaces === undefined ? {} : { namespaces: inventory.namespaces }),
tools: renderCatalog(root),
} satisfies CodeModeCatalog.Inventory
}
type CatalogNode = Node<CodeModeCatalog.Tool>
function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool | CodeModeCatalog.Namespace> {
return Array.from(root.children)
.toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.flatMap(([name, node]) => {
const tools = renderCatalog(node)
const namespace =
node.namespace === undefined && tools.length === 0
? undefined
: {
type: "namespace" as const,
name,
...(node.namespace?.description === undefined ? {} : { description: node.namespace.description }),
tools,
}
if (node.tool === undefined) return namespace === undefined ? [] : [namespace]
if (namespace === undefined) return [node.tool]
return [node.tool, namespace]
})
}
function runtime(
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
@@ -191,9 +222,9 @@ function runtime(
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function getNode(root: ToolNode, path: string) {
function getNode<T>(root: Node<T>, path: string) {
return path.split(".").reduce((parent, name) => {
const child: ToolNode = parent.children.get(name) ?? { children: new Map() }
const child: Node<T> = parent.children.get(name) ?? { children: new Map() }
parent.children.set(name, child)
return child
}, root)
+2 -4
View File
@@ -7,7 +7,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
@@ -81,7 +80,6 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = resolvePath(location.directory, input.path)
@@ -113,7 +111,7 @@ const layer = Layer.effect(
resource: externalResource,
save: slash(
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
"*",
),
),
@@ -128,5 +126,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
deps: [FSUtil.node, Location.node],
})
-2
View File
@@ -10,7 +10,6 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -28,7 +27,6 @@ const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)
+13 -9
View File
@@ -30,7 +30,7 @@ import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
plugins: readonly Versioned[],
plugins: readonly Generation[],
failures?: readonly Failure[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
@@ -38,8 +38,8 @@ export interface Interface {
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
export type Versioned = PluginDefinition & {
readonly version: string
export type Generation = PluginDefinition & {
readonly revision: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
@@ -52,11 +52,11 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const kv = yield* KV.Service
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) {
const load = Effect.fnUntraced(function* (plugin: Generation) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() =>
@@ -83,7 +83,7 @@ const layer = Layer.effect(
})
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Versioned[],
plugins: readonly Generation[],
failures: readonly Failure[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
@@ -99,10 +99,14 @@ const layer = Layer.effect(
active.size === definitions.length &&
Array.from(active.values()).every((entry, index) => {
const definition = definitions[index]
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
})
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
for (const definition of definitions) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
const nextInventory = [...definitions.map(activeInfo), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
@@ -174,7 +178,7 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned): Plugin.Info {
function activeInfo(plugin: Generation): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
+7 -10
View File
@@ -3,7 +3,7 @@ export * as InstancePlugins from "./instance.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Versioned } from "../plugin.js"
import type { Generation } from "../plugin.js"
/**
* Holds the plugins one instance is born with. Unlike the host-global
@@ -13,16 +13,13 @@ import type { Versioned } from "../plugin.js"
* for the instance's lifetime; runtime dynamism lives inside plugins through
* the container transform/reload APIs.
*
* Limitations: `vcs` marker declarations in an instance list are not seen by
* `ProjectMarkers` (it is global and runs during project resolution, before
* the instance exists unlike `SdkPlugins`, whose declarations it consumes
* directly), and config plugin operations may disable instance plugins by id,
* matching `SdkPlugins` behavior.
* Config plugin operations may disable instance plugins by id, matching
* `SdkPlugins` behavior.
*/
export type List = readonly Plugin[]
export interface Interface {
readonly all: () => readonly Versioned[]
readonly all: () => readonly Generation[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
@@ -33,8 +30,8 @@ export const node = makeLocationNode({
deps: [],
})
// The constant version is load-bearing: the plugin registry treats an
// unchanged (id, version) pair as the same plugin across activations, which
// The constant revision is load-bearing: the plugin registry treats an
// unchanged (id, revision) pair as the same plugin across activations, which
// is only correct because a bound list never changes after creation.
// `source: "sdk"` means host-contributed; an instance list is the
// per-instance form of the same channel.
@@ -44,7 +41,7 @@ export function bound(plugins: List) {
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
}
const stamped = plugins.map(
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
(plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }),
)
return Layer.succeed(Service, Service.of({ all: () => stamped }))
}
-5
View File
@@ -26,7 +26,6 @@ import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
@@ -97,7 +96,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const pluginSources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
@@ -141,7 +139,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(ConfigPluginSource.Service, pluginSources),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
@@ -192,7 +189,6 @@ export const requirements = LayerNode.group([
Command.node,
Config.node,
Credential.node,
ConfigPluginSource.node,
Bus.node,
Environment.node,
FileMutation.node,
@@ -287,7 +283,6 @@ export const list = Effect.fn("PluginInternal.list")(function* () {
plugins.map(
(plugin): Plugin => ({
id: plugin.id,
vcs: plugin.vcs,
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
}),
)
+15 -14
View File
@@ -8,23 +8,17 @@ import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import type { Generation } from "../plugin.js"
import { PluginPromise } from "./promise.js"
const Discovery = Schema.Struct({
id: Schema.optional(Schema.String),
markers: Schema.Array(Schema.String),
})
const Definition = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
@@ -34,13 +28,17 @@ const Definition = Schema.Struct({
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
) {
const npm = yield* Npm.Service
const local = path.isAbsolute(operation.target)
const installed = local
? { entrypoint: pathToFileURL(operation.target).href }
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
: options?.install === false
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
@@ -63,13 +61,16 @@ export const load = Effect.fn("PluginModule.load")(function* (
return {
id: plugin.id,
features,
vcs: plugin.vcs,
version: JSON.stringify(operation),
revision: JSON.stringify([operation, installed.revision]),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
: {
type: "package" as const,
target: operation.target,
...(installed.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Versioned
} satisfies Generation
})
function localFeatures(entrypoint: string) {
+4 -4
View File
@@ -4,7 +4,7 @@ import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import type { Versioned } from "../plugin.js"
import type { Generation } from "../plugin.js"
export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
@@ -21,7 +21,7 @@ export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
*/
export interface Interface {
readonly register: (plugin: Plugin) => Effect.Effect<void>
readonly all: () => readonly Versioned[]
readonly all: () => readonly Generation[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
@@ -30,12 +30,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const plugins = new Map<string, Versioned>()
const plugins = new Map<string, Generation>()
let revision = 0
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
plugins.set(plugin.id, { ...plugin, revision: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
+7 -4
View File
@@ -3,10 +3,11 @@
export * as SkillPlugin from "./skill.js"
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Skill } from "../skill.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { Config } from "../config.js"
import os from "os"
import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
@@ -68,9 +69,11 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
})
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
const sources = yield* ConfigPluginSource.Service
return (yield* sources.operations())
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
const config = yield* Config.Service
return (yield* config.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => entry.info.plugins ?? [])
.map((entry) => (typeof entry === "string" ? entry : entry.package))
.toSorted()
})
@@ -7,7 +7,10 @@ import { Context, Effect } from "effect"
* imports: the supervisor reaches PluginRuntime, which depends on Session.
*/
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
/**
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
* UI startup, or other unrelated work on plugin boot should be avoided.
*/
readonly flush: Effect.Effect<void>
}
+65 -29
View File
@@ -14,17 +14,20 @@ import { PluginInternal } from "./internal.js"
import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
import { PluginUpdate } from "./update.js"
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
pre: readonly Plugin.Generation[],
post: readonly Plugin.Generation[],
operations: readonly ConfigPluginSource.Operation[],
install: boolean,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const packages = new Map<string, Plugin.Generation>()
const pending = new Set<string>()
const failures = new Map<
string,
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
@@ -51,13 +54,17 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* PluginModule.load(operation).pipe(
const plugin = yield* PluginModule.load(operation, { install }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if ("pending" in plugin) {
pending.add(operation.target)
continue
}
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
@@ -80,9 +87,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
pending: [...pending],
}
})
@@ -94,45 +99,74 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const updates = yield* PluginUpdate.Service
const ready = yield* Latch.make()
let packages = new Set<string>()
let outdated = new Set<string>()
let generation = 0
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
const current = ++generation
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
...internal.pre.map((plugin) => ({ ...plugin, revision: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
...instance.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
version: "internal",
revision: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const source = (source: Plugin.Source) =>
source.type === "package" && outdated.has(source.target)
? { ...source, outdated: true as const }
: source
const apply = (resolved: typeof immediate) =>
registry.activate(
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
)
yield* apply(immediate)
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true) : immediate
if (resolved !== immediate) yield* apply(resolved)
const loaded = new Set(
[...resolved.plugins, ...resolved.failures].flatMap((plugin) =>
plugin.source?.type === "package" ? [plugin.source.target] : [],
),
)
packages = loaded
yield* Effect.forEach(
loaded,
(target) => updates.check(target).pipe(Effect.map((available) => [target, available] as const)),
{ concurrency: "unbounded" },
).pipe(
Effect.flatMap((checked) => {
if (current !== generation) return Effect.void
const next = new Set(checked.flatMap(([target, available]) => (available ? [target] : [])))
if (next.size === outdated.size && [...next].every((target) => outdated.has(target))) return Effect.void
outdated = next
return apply(resolved)
}),
Effect.forkScoped({ startImmediately: true }),
)
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
const reloads = Stream.merge(
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
updates.changes().pipe(
Stream.filter((target) => packages.has(target)),
Stream.tap((target) => Effect.sync(() => outdated.delete(target))),
Stream.map(() => undefined),
),
).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
@@ -142,7 +176,7 @@ export const layer = Layer.effect(
}),
),
)
yield* Stream.concat(Stream.succeed(0), updates).pipe(
yield* Stream.concat(Stream.succeed(0), reloads).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.debounce("100 millis"),
@@ -154,6 +188,7 @@ export const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("24 hours").pipe(Effect.andThen(activate()), Effect.forever, Effect.forkScoped)
return Service.of({ flush: ready.await })
}),
)
@@ -163,6 +198,7 @@ const nodeDeps = [
SdkPlugins.node,
InstancePlugins.node,
ConfigPluginSource.node,
PluginUpdate.node,
Bus.node,
Npm.node,
PluginInternal.requirements,
@@ -170,7 +206,7 @@ const nodeDeps = [
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", package: target }
return { type: "package", target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+54
View File
@@ -0,0 +1,54 @@
export * as PluginUpdate from "./update.js"
import { Clock, Context, Effect, Layer, Option, PubSub, Stream } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { KeyedMutex } from "../effect/keyed-mutex.js"
const interval = 24 * 60 * 60 * 1_000
export interface Interface {
readonly check: (target: string) => Effect.Effect<boolean>
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
readonly changes: () => Stream.Stream<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const npm = yield* Npm.Service
const locks = KeyedMutex.makeUnsafe<string>()
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
const changes = yield* PubSub.unbounded<string>()
return Service.of({
check: (target) =>
locks.withLock(target)(
Effect.gen(function* () {
const checkedAt = yield* Clock.currentTimeMillis
const current = status.get(target)
if (current && checkedAt - current.checkedAt < interval) return current.outdated
const outdated = yield* npm.check(target).pipe(
Effect.tapCause((cause) => Effect.logWarning("failed to check plugin update", { target, cause })),
Effect.option,
)
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
status.set(target, { outdated: value, checkedAt })
return value
}),
),
update: (target) =>
npm.update(target).pipe(
Effect.tap(() => Effect.sync(() => status.delete(target))),
Effect.tap(() => PubSub.publish(changes, target)),
Effect.asVoid,
),
changes: () => Stream.fromPubSub(changes),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Npm.node] })
-1
View File
@@ -20,7 +20,6 @@ import type { Patch } from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.git",
vcs: { id: "git", markers: [".git"] },
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
-1
View File
@@ -24,7 +24,6 @@ import {
export const Plugin = define({
id: "opencode.vcs.hg",
vcs: { id: "hg", markers: [".hg"] },
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
+5 -22
View File
@@ -13,7 +13,6 @@ import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectMarkers } from "./project/markers.js"
import { ProjectSchema } from "./project/schema.js"
import { ProjectTable, upsertProject } from "./project/sql.js"
import { WorktreeTable } from "./worktree/sql.js"
@@ -44,7 +43,6 @@ export interface Resolved {
// This checkout's main directory; the stored project canonical may be another clone.
readonly canonical: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: string
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
@@ -98,7 +96,6 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const markers = yield* ProjectMarkers.Service
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
@@ -172,7 +169,7 @@ const layer = Layer.effect(
if (candidate.id === item.projectID) return false
if (!FSUtil.contains(directory, candidate.directory)) return false
const found = yield* fs
.up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
.up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
.pipe(Effect.orElseSucceed(() => []))
if (!found[0]) return false
return (yield* fs.resolve(path.dirname(found[0]))) === directory
@@ -318,10 +315,9 @@ const layer = Layer.effect(
const resolve = Effect.fn("Project.resolve")(function* (
input: AbsolutePath,
options?: { readonly discovery?: boolean },
_options?: { readonly discovery?: boolean },
) {
const directory = AbsolutePath.make(yield* fs.resolve(input))
const marker = yield* markers.discover(directory, options)
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
@@ -330,7 +326,7 @@ const layer = Layer.effect(
native && path.basename(native) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
: undefined
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
const canonical =
@@ -346,27 +342,14 @@ const layer = Layer.effect(
directory: repo.worktree,
canonical,
vcs: { type: "git" as const, store: repo.commonDirectory },
...(marker?.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}),
})
}
const hg = native && path.basename(native) === ".hg" ? yield* hgDiscover(AbsolutePath.make(native)) : undefined
if (hg && (!marker || FSUtil.contains(marker.directory, hg.directory))) {
if (hg) {
return yield* persist({
...hg,
canonical: hg.directory,
...(marker?.directory === hg.directory && marker.type !== "hg" ? { vcsBackend: marker.type } : {}),
})
}
if (marker) {
const previous = yield* cached(marker.marker)
return yield* persist({
previous,
id: previous ?? ID.make(Hash.fast(`vcs-repository:${marker.type}:${marker.marker}`)),
directory: marker.directory,
canonical: marker.directory,
vcs: { type: marker.type, store: marker.marker },
})
}
@@ -385,5 +368,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
})
-191
View File
@@ -1,191 +0,0 @@
export * as ProjectMarkers from "./markers.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import { fileURLToPath } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import { PluginModule } from "../plugin/module.js"
import { PluginSourceDirectory } from "../plugin/source-directory.js"
import { SdkPlugins } from "../plugin/sdk.js"
import { AbsolutePath } from "../schema.js"
export interface Match {
readonly type: string
readonly directory: AbsolutePath
readonly marker: AbsolutePath
}
export interface Interface {
readonly discover: (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) => Effect.Effect<Match | undefined>
readonly targets: () => readonly string[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectMarkers") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const npm = yield* Npm.Service
const sdk = yield* SdkPlugins.Service
const known = new Set([".git", ".hg"])
const loaded = new Map<string, Versioned | undefined>()
// The filesystem half of discovery: walk up for config, scan plugin
// directories, and read configured plugin operations. This is the part a
// no-discovery caller must skip — it imports plugin modules.
const scanOperations = Effect.fnUntraced(function* (directory: AbsolutePath) {
const found = yield* fs
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
.pipe(Effect.orElseSucceed(() => []))
const roots = [global.config, ...found.filter((value) => path.basename(value) === ".opencode").toReversed()]
const files = [
...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)),
...found.filter((value) => path.basename(value) !== ".opencode").toReversed(),
...roots.slice(1).flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))),
]
const automatic = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe(
Effect.map((entries) => entries.flat()),
)
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
Effect.map((entries) => entries.flat()),
)
return yield* Effect.forEach(
[
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
...configured,
],
(operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
},
)
})
const discover = Effect.fn("ProjectMarkers.discover")(function* (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) {
// discovery: false skips the config scan and its plugin module loading;
// sdk-declared vcs markers are host-explicit, not ambient, so they stay.
const operations = options?.discovery === false ? [] : yield* scanOperations(directory)
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
for (const plugin of sdk.all()) {
if (!plugin.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
for (const operation of operations) {
if (operation.type === "remove") {
for (const id of declarations.keys()) {
if (
operation.target === "*" ||
(operation.target.endsWith(".*") ? id.startsWith(operation.target.slice(0, -1)) : operation.target === id)
) {
declarations.delete(id)
}
}
continue
}
if (operation.target === "*" || operation.target.endsWith(".*") || operation.target.startsWith("opencode."))
continue
const key = JSON.stringify(operation)
const plugin = loaded.has(key)
? loaded.get(key)
: yield* PluginModule.load(operation).pipe(
Effect.provideService(Npm.Service, npm),
Effect.catchCause((cause) =>
Effect.logDebug("failed to discover plugin repository markers", {
target: operation.target,
cause,
}).pipe(Effect.as(undefined)),
),
Effect.tap((value) => Effect.sync(() => loaded.set(key, value))),
)
if (!plugin?.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
const markers = new Map<string, string>()
for (const declaration of declarations.values()) {
if (!/^[a-z][a-z0-9._-]*$/.test(declaration.id)) continue
for (const marker of declaration.markers) {
if (!marker || marker === "." || marker === ".." || /[\\/]/.test(marker)) continue
known.add(marker)
markers.set(marker, declaration.id)
}
}
if (!markers.size) return undefined
const marker = yield* fs.up({ targets: [...markers.keys()], start: directory, mode: "first" }).pipe(
Effect.map((entries) => entries[0]),
Effect.orElseSucceed(() => undefined),
)
if (!marker) return undefined
const type = markers.get(path.basename(marker))
if (!type) return undefined
return {
type,
directory: AbsolutePath.make(path.dirname(marker)),
marker: AbsolutePath.make(marker),
} satisfies Match
})
return Service.of({ discover, targets: () => [...known] })
}),
)
function read(fs: FSUtil.Interface, file: string): Effect.Effect<ConfigPluginSource.Operation[]> {
return Effect.gen(function* () {
const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
if (!source) return []
const errors: ParseError[] = []
const document: unknown = parse(source, errors, { allowTrailingComma: true })
if (errors.length || typeof document !== "object" || document === null || !("plugins" in document)) return []
if (!Array.isArray(document.plugins)) return []
return document.plugins.flatMap<ConfigPluginSource.Operation>((entry) => {
if (typeof entry === "string" && entry.startsWith("-")) {
return [{ type: "remove", target: entry.slice(1) }]
}
if (
typeof entry !== "string" &&
(typeof entry !== "object" || entry === null || !("package" in entry) || typeof entry.package !== "string")
) {
return []
}
const target = typeof entry === "string" ? entry : entry.package
const options =
typeof entry !== "string" && "options" in entry && typeof entry.options === "object" && entry.options !== null
? Object.fromEntries(Object.entries(entry.options))
: {}
if (target.startsWith("file://")) return [{ type: "add", target: fileURLToPath(target), options }]
if (target.startsWith("./") || target.startsWith("../")) {
return [{ type: "add", target: path.resolve(path.dirname(file), target), options }]
}
return [{ type: "add", target, options }]
})
})
}
export const node = makeGlobalNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Npm.node, SdkPlugins.node],
})
+1
View File
@@ -18,6 +18,7 @@ const jsonSchemas = Effect.runSync(
)
export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
type: "tool",
name: effectiveName(tool),
description: tool.description,
inputSchema: inputJsonSchema(tool.input),
+1 -1
View File
@@ -82,7 +82,7 @@ const layer = Layer.effect(
})
const selected = () => {
const value = state.get()
const id = value.selection ?? location.vcsBackend ?? vcs?.type
const id = value.selection ?? vcs?.type
return id ? value.providers.get(id) : undefined
}
const protect = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, fallback: A) =>
+12 -5
View File
@@ -10,7 +10,8 @@ describe("CodeMode", () => {
it.effect("owns registrations, execute, and catalog materialization", () =>
Effect.gen(function* () {
const tools = yield* Tool.Service
yield* tools.transform((draft) =>
yield* tools.transform((draft) => {
draft.namespace({ name: "empty", description: "No tools registered yet" })
draft.add({
name: "echo",
description: "Echo text",
@@ -18,21 +19,27 @@ describe("CodeMode", () => {
output: Schema.String,
options: { pinned: true },
execute: ({ text }) => Effect.succeed({ output: text }),
}),
)
})
})
const snapshot = yield* tools.snapshot()
expect(snapshot.definitions.some((tool) => tool.name === "execute")).toBe(true)
expect(snapshot.codeModeCatalog).toStrictEqual({
tools: [
{
path: "echo",
type: "tool",
name: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
{
type: "namespace",
name: "empty",
description: "No tools registered yet",
tools: [],
},
],
namespaces: new Map(),
})
}).pipe(
Effect.scoped,
+11 -12
View File
@@ -3,7 +3,8 @@ import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Tool => ({
path,
type: "tool",
name: path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
pinned,
@@ -15,12 +16,12 @@ const lookup = entry(
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
)
const render = (tools: ReadonlyArray<CodeModeCatalog.Tool>, budget?: number) =>
const render = (tools: CodeModeCatalog.Inventory["tools"], budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize({ tools }, budget === undefined ? {} : { budget }))
const update = (
previous: ReadonlyArray<CodeModeCatalog.Tool>,
current: ReadonlyArray<CodeModeCatalog.Tool>,
previous: CodeModeCatalog.Inventory["tools"],
current: CodeModeCatalog.Inventory["tools"],
budget?: number,
) =>
CodeModeInstructions.update(
@@ -103,10 +104,10 @@ describe("CodeModeCatalog.summarize", () => {
const listingCost = Math.round(` - ${tool.signature} // One`.length / 4)
const namespaceCost = Math.round(CodeModeCatalog.namespaceLine({ name: "alpha", count: 1, entries: [] }).length / 4)
const description = "A namespace description that stays visible beyond the available tool budget"
const namespaces = new Map([["alpha", { name: "alpha", description }]])
const namespace = { type: "namespace" as const, name: "alpha", description, tools: [tool] }
expect(CodeModeCatalog.summarize({ tools: [tool] }, { budget: namespaceCost + listingCost }).shown).toBe(1)
const catalog = CodeModeCatalog.summarize({ tools: [tool], namespaces }, { budget: namespaceCost + listingCost })
const catalog = CodeModeCatalog.summarize({ tools: [namespace] }, { budget: namespaceCost + listingCost })
expect(catalog.shown).toBe(0)
expect(catalog.namespaces[0]?.description).toBe(description)
expect(CodeModeInstructions.render(catalog)).toContain(`- alpha (1 tool, none shown) // ${description}`)
@@ -136,8 +137,8 @@ describe("CodeModeInstructions.render", () => {
)
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" /**\n * @integer\n * @exclusiveMinimum 0\n */\n limit?: number,")
expect(partial).toContain(" /**\n * @integer\n * @minimum 0\n */\n offset?: number,")
expect(partial).toContain(" /** @integer @exclusiveMinimum 0 */\n limit?: number,")
expect(partial).toContain(" /** @integer @minimum 0 */\n offset?: number,")
expect(partial).not.toContain("tools.orders.lookup(input:")
})
@@ -209,12 +210,10 @@ describe("CodeModeInstructions.update", () => {
test("restates namespace descriptions when they change", () => {
const previous = CodeModeCatalog.summarize({
tools: [echo],
namespaces: new Map([["notes", { name: "notes", description: "Old description" }]]),
tools: [{ type: "namespace", name: "notes", description: "Old description", tools: [echo] }],
})
const current = CodeModeCatalog.summarize({
tools: [echo],
namespaces: new Map([["notes", { name: "notes", description: "New description" }]]),
tools: [{ type: "namespace", name: "notes", description: "New description", tools: [echo] }],
})
const text = CodeModeInstructions.update(previous, current)
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
@@ -10,13 +10,15 @@ import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
const echo: CodeModeCatalog.Tool = {
path: "notes.echo",
type: "tool",
name: "notes.echo",
description: "Echo text",
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
}
const lookup: CodeModeCatalog.Tool = {
path: "orders.lookup",
type: "tool",
name: "orders.lookup",
description: "Look up an order",
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
}
+90 -55
View File
@@ -20,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
import { Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -37,38 +37,79 @@ const staticIt = testEffect(
Global.node.replace(tempGlobalLayer),
]),
)
const refreshNpm = makeGlobalNode({
const outdatedNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
const directory = path.join(global.tmp, "outdated-plugin")
let version = "1.0.0"
const installed = () => ({
directory,
entrypoint: pathToFileURL(path.join(directory, "index.js")).href,
version,
revision: version,
})
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await Bun.write(path.join(directory, "index.js"), 'export default { id: "outdated-plugin", setup() {} }')
})
return Npm.Service.of({
add: (_pkg, options) =>
options?.refresh
? Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
return installed
})
: Effect.succeed(installed),
resolve: () => Effect.succeed(installed),
add: () => Effect.sync(installed),
resolve: () => Effect.sync(installed),
check: () => Effect.sync(() => version === "1.0.0"),
update: () => Effect.sync(() => (version = "1.1.0")).pipe(Effect.map(installed)),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const refreshIt = testEffect(
const updateIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
[Global.node.replace(tempGlobalLayer), Npm.node.replace(outdatedNpm)],
),
)
const coldNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "cold-plugin")
const started = path.join(directory, "started")
const release = path.join(directory, "release")
const entry = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href, revision: "1" }
let installed = false
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await Bun.write(path.join(directory, "index.js"), 'export default { id: "cold-plugin", setup() {} }')
})
return Npm.Service.of({
add: () =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(started, ""))
yield* waitForFile(release).pipe(Effect.orDie)
installed = true
return entry
}),
resolve: () => Effect.sync(() => (installed ? entry : { directory })),
check: () => Effect.succeed(false),
update: () => Effect.succeed(entry),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const coldIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(coldNpm)],
),
)
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
withLocation(
@@ -463,46 +504,40 @@ describe("PluginSupervisor config", () => {
}),
)
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const activated = path.join(directory, "activated")
const release = path.join(directory, "release")
const refreshed = path.join(directory, "refresh-requested")
const refreshRelease = path.join(directory, "refresh-release")
const refreshFinished = path.join(directory, "refresh-finished")
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(
path.join(directory, "index.js"),
`export default {
id: "background-refresh-plugin",
async setup() {
await Bun.write(${JSON.stringify(activated)}, "")
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
},
}`,
updateIt.live("marks active package plugins as outdated after a background check", () =>
withLocation(
{ plugins: ["outdated-plugin"] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const source = yield* Effect.suspend(() => plugins.list()).pipe(
Effect.map((items) => items.find((item) => item.id === "outdated-plugin")?.source),
Effect.filterOrFail((source) => source?.type === "package" && source.outdated === true),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
)
})
yield* withLocation(
{ plugins: ["background-refresh-plugin"] },
Effect.gen(function* () {
yield* waitForFile(activated)
yield* Effect.sleep("100 millis")
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
yield* Effect.promise(() => Bun.write(release, ""))
yield* waitForFile(refreshed)
yield* ready().pipe(Effect.timeout("2 seconds"))
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
yield* waitForFile(refreshFinished)
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
}),
)
}),
expect(source).toEqual({ type: "package", target: "outdated-plugin", version: "1.0.0", outdated: true })
}),
),
)
coldIt.live("activates available plugins before a missing package finishes installing", () =>
withLocation(
{ plugins: ["cold-plugin"] },
Effect.gen(function* () {
const global = yield* Global.Service
yield* waitForFile(path.join(global.tmp, "cold-plugin", "started"))
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("opencode.provider.openai")
const supervisor = yield* PluginSupervisor.Service
expect(Option.isNone(yield* supervisor.flush.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
yield* supervisor.flush.pipe(Effect.timeout("2 seconds"))
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("cold-plugin")
}),
),
)
})
const ready = Effect.fnUntraced(function* () {
@@ -30,7 +30,9 @@ export const promptLocationNode = makeGlobalNode({
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
}),
Layer.succeed(FSUtil.Service, fs),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
}),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
),
+6 -1
View File
@@ -1,4 +1,5 @@
import { Agent } from "@opencode-ai/core/agent"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import type { Permission } from "@opencode-ai/core/permission"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
@@ -16,6 +17,9 @@ export const toolIdentity = {
export const toolDefinitions = (registry: Tool.Interface, permissions?: Permission.Ruleset) =>
registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions))
export const codeModeListings = (catalog: CodeModeCatalog.Inventory) =>
CodeModeCatalog.summarize(catalog, { budget: Infinity }).namespaces.flatMap((namespace) => namespace.entries)
export function waitForTool(registry: Tool.Interface, name: string, remaining = 1000): Effect.Effect<void, Error> {
return Effect.gen(function* () {
if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return
@@ -35,7 +39,8 @@ export function waitForCodeModeTool(
): Effect.Effect<Tool.Snapshot, Error> {
return Effect.gen(function* () {
const toolSet = yield* registry.snapshot()
if (toolSet.codeModeCatalog?.tools.some((tool) => tool.path === path)) return toolSet
if (toolSet.codeModeCatalog && codeModeListings(toolSet.codeModeCatalog).some((tool) => tool.path === path))
return toolSet
if (remaining === 0) {
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
}
+1 -1
View File
@@ -940,7 +940,7 @@ describe("LocationServiceMap", () => {
})
.pipe(Effect.asVoid),
})
yield* plugins.activate([{ ...reviewer, version: "1" }])
yield* plugins.activate([{ ...reviewer, revision: "1" }])
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
+6 -6
View File
@@ -46,7 +46,7 @@ import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { tmpdirScoped } from "./fixture/tmpdir"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
import { codeModeListings, executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
@@ -1764,7 +1764,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
"direct_media",
"execute",
])
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).toContain(
expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).toContain(
"ok: boolean",
)
expect(execute?.description).not.toContain("tools.demo.search")
@@ -1784,7 +1784,7 @@ it.effect("forwards the invoking session through direct and Code Mode MCP tools"
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
"properties.sessionID",
)
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).not.toContain(
expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).not.toContain(
"sessionID",
)
@@ -1830,7 +1830,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.status")).toBe(true)
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.status")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
@@ -1916,7 +1916,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
const fiber = yield* toolSet
.execute({
@@ -1960,7 +1960,7 @@ it.effect("does not call MCP when permission is blocked", () =>
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_blocked"),
+140 -31
View File
@@ -55,6 +55,58 @@ async function createGitFixture(directory: string) {
return { repository, commit }
}
async function createRegistryFixture(directory: string) {
const tarballs = new Map<string, Uint8Array>()
for (const version of ["1.0.0", "1.1.0"]) {
const root = path.join(directory, version)
await fs.mkdir(path.join(root, "package"), { recursive: true })
await writePackage(path.join(root, "package"), {
name: "@fixture/registry-plugin",
version,
exports: "./index.js",
})
await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`)
await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package`
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = { latest: "1.0.0" }
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
return Response.json({
name: "@fixture/registry-plugin",
"dist-tags": { latest: state.latest },
versions: Object.fromEntries(
[...tarballs.keys()].map((version) => [
version,
{ name: "@fixture/registry-plugin", version, dist: { tarball: `${url.origin}/${version}.tgz` } },
]),
),
})
const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", ""))
return tarball ? new Response(tarball) : new Response("missing", { status: 404 })
},
})
return {
state,
async configure(cache: string, spec: string) {
const root = path.join(cache, "npm", await Npm.cacheKey(spec))
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
)
return root
},
async [Symbol.asyncDispose]() {
await server.stop(true)
},
}
}
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -98,10 +150,12 @@ describe("Npm.isInstallablePackage", () => {
})
describe("Npm.cacheKey", () => {
test("preserves registry keys and hashes Git specs", async () => {
test("canonicalizes registry keys and hashes Git specs", async () => {
expect(await Npm.cacheKey("@opencode/acme@1.0.0")).toBe(Npm.sanitize("@opencode/acme@1.0.0"))
expect(await Npm.cacheKey("plugin")).toBe(Npm.sanitize("plugin@latest"))
expect(await Npm.cacheKey("@opencode/acme")).toBe(Npm.sanitize("@opencode/acme@latest"))
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)).toMatch(/^git-plugin-[a-f0-9]{12}$/)
expect(await Npm.cacheKey(spec)).toBe(await Npm.cacheKey(spec))
expect(await Npm.cacheKey(`${spec}-other`)).not.toBe(await Npm.cacheKey(spec))
})
@@ -114,8 +168,9 @@ describe("Npm.add", () => {
const directory = path.join(
tmp.path,
"cache",
"packages",
Npm.sanitize(spec),
"npm",
await Npm.cacheKey(spec),
"1000",
"node_modules",
"@fixture",
"provider",
@@ -136,7 +191,7 @@ describe("Npm.add", () => {
test("falls back to the original spec when parsing fails", async () => {
await using tmp = await tmpdir()
const spec = "fixture provider"
const directory = path.join(tmp.path, "cache", "packages", Npm.sanitize(spec), "node_modules", spec)
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "node_modules", spec)
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, { name: spec, exports: "./index.js" })
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
@@ -164,7 +219,7 @@ describe("Npm.add", () => {
await Bun.write(path.join(tmp.path, "fixture-provider", "tui.js"), "export const tui = true\n")
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
await fs.mkdir(path.join(tmp.path, "cache", "npm", Npm.sanitize(spec)), { recursive: true })
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
@@ -198,12 +253,14 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(entries.added.entrypoint).toEndWith("/index.js")
expect(entries.added.version).toBe(fixture.commit)
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"))
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
expect(entries.added.directory).toContain("node_modules")
}
})
@@ -223,8 +280,8 @@ describe("Npm.add", () => {
).toBeTruthy()
})
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
// Several real Git installs and updates exceed Bun's default timeout on Windows.
test("checks and updates mutable Git packages without changing pinned installs", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
@@ -232,37 +289,38 @@ describe("Npm.add", () => {
const mutable = `git+${repository}#fixture-branch`
const pinned = `git+${repository}#${fixture.commit}`
const first = await Effect.gen(function* () {
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable)
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
const pinnedEntry = yield* npm.add(pinned)
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
const before = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text())
const outdated = yield* npm.check(mutable)
const pinnedOutdated = yield* npm.check(pinned)
const unchanged = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text())
const updated = yield* npm.update(mutable)
const pinnedUpdated = yield* npm.update(pinned)
return {
mutable: yield* npm.add(mutable, { refresh: true }),
pinned: yield* npm.add(pinned, { refresh: true }),
before,
outdated,
pinnedOutdated,
unchanged,
updated: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
pinned: yield* Effect.promise(() => Bun.file(path.join(pinnedUpdated.directory, "index.js")).text()),
current: yield* npm.check(mutable),
}
}).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"')
expect(result.before).toContain("root: true")
expect(result.outdated).toBeTrue()
expect(result.pinnedOutdated).toBeFalse()
expect(result.unchanged).toContain("root: true")
expect(result.updated).toContain('root: "second"')
expect(result.pinned).toContain("root: true")
expect(result.current).toBeFalse()
}, 30_000)
})
@@ -271,7 +329,7 @@ describe("Npm.resolve", () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const spec = "fixture-plugin@1.0.0"
const directory = path.join(cache, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
const missing = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
@@ -291,5 +349,56 @@ describe("Npm.resolve", () => {
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
expect(resolved.version).toBe("1.0.0")
})
})
describe("Npm.check and Npm.update", () => {
test("checks registry targets without mutation and explicitly updates mutable targets", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const mutable = "@fixture/registry-plugin@latest"
const pinned = "@fixture/registry-plugin@1.0.0"
const root = await registry.configure(cache, mutable)
await registry.configure(cache, pinned)
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
const installed = yield* npm.add(mutable)
yield* npm.add(pinned)
const current = yield* npm.check(mutable)
registry.state.latest = "1.1.0"
const outdated = yield* npm.check(mutable)
const pinnedOutdated = yield* npm.check(pinned)
const before = yield* Effect.promise(() => Bun.file(path.join(installed.directory, "index.js")).text())
yield* Effect.promise(() => Promise.all([fs.mkdir(path.join(root, "1")), fs.mkdir(path.join(root, "2"))]))
const updated = yield* npm.update(mutable)
const unchanged = yield* npm.update(mutable)
return {
current,
outdated,
pinnedOutdated,
before,
after: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
version: updated.version,
changedDirectory: installed.directory !== updated.directory,
unchangedDirectory: unchanged.directory === updated.directory,
generations: yield* Effect.promise(() => fs.readdir(root)),
updated: yield* npm.check(mutable),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(result.current).toBeFalse()
expect(result.outdated).toBeTrue()
expect(result.pinnedOutdated).toBeFalse()
expect(result.before).toContain('version = "1.0.0"')
expect(result.after).toContain('version = "1.1.0"')
expect(result.version).toBe("1.1.0")
expect(result.changedDirectory).toBeTrue()
expect(result.unchangedDirectory).toBeTrue()
expect(result.generations).not.toContain("1")
expect(result.generations).not.toContain("2")
expect(result.updated).toBeFalse()
})
})
+47 -28
View File
@@ -24,7 +24,7 @@ const it = testEffect(PluginTestLayer)
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
const generation = <R>(plugin: EffectPlugin.Plugin<R>, revision = "1") => ({ ...plugin, revision })
describe("Plugin", () => {
it.effect("routes experimental terminal reads through the runtime cell without wrapping results", () =>
@@ -81,7 +81,7 @@ describe("Plugin", () => {
const location = yield* Location.Service
const seen: Location.Info[] = []
yield* plugins.activate([
versioned(
generation(
EffectPlugin.define({
id: "location-context",
effect: (ctx) =>
@@ -215,7 +215,7 @@ describe("Plugin", () => {
.pipe(Effect.asVoid),
})
yield* plugins.activate([versioned(provider)])
yield* plugins.activate([generation(provider)])
expect(yield* vcs.info()).toEqual({ branch: { current: "feature" } })
expect(yield* vcs.branches()).toEqual(["feature"])
@@ -224,7 +224,7 @@ describe("Plugin", () => {
}),
)
it.effect("replaces plugins by ID and version", () =>
it.effect("replaces plugins by ID and revision", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
@@ -250,24 +250,24 @@ describe("Plugin", () => {
.pipe(Effect.asVoid),
})
yield* plugins.activate([versioned(managed(), "1")])
yield* plugins.activate([generation(managed(), "1")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("first")
description = "second"
yield* plugins.activate([versioned(managed(), "2")])
yield* plugins.activate([generation(managed(), "2")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
description = "third"
yield* plugins.activate([versioned(managed(), "2")])
yield* plugins.activate([generation(managed(), "2")])
expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[versioned(managed(), "2")],
[generation(managed(), "2")],
[
{
source: { type: "package", package: "broken" },
source: { type: "package", target: "broken" },
state: { status: "failed", error: "failed to resolve" },
features: { server: true },
},
@@ -288,7 +288,7 @@ describe("Plugin", () => {
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const definitions = ["first", "second"].map((id) =>
versioned(
generation(
EffectPlugin.define({
id,
effect: (ctx) => ctx.agent.transform((draft) => draft.update(id, () => {})),
@@ -315,17 +315,36 @@ describe("Plugin", () => {
}),
)
it.effect("updates inventory metadata without restarting an unchanged generation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
let loads = 0
const plugin = {
id: "metadata",
revision: "1",
source: { type: "package" as const, target: "fixture" },
effect: () => Effect.sync(() => loads++),
}
yield* plugins.activate([plugin])
yield* plugins.activate([{ ...plugin, source: { ...plugin.source, outdated: true } }])
expect(loads).toBe(1)
expect((yield* plugins.list())[0]?.source).toEqual({ type: "package", target: "fixture", outdated: true })
}),
)
it.effect("rejects duplicate IDs before replacing active plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const active = Plugin.ID.make("active")
const duplicate = "duplicate"
yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }])
yield* plugins.activate([{ id: active, revision: "1", effect: () => Effect.void }])
const result = yield* plugins
.activate([
{ id: duplicate, version: "1", effect: () => Effect.void },
{ id: duplicate, version: "1", effect: () => Effect.void },
{ id: duplicate, revision: "1", effect: () => Effect.void },
{ id: duplicate, revision: "1", effect: () => Effect.void },
])
.pipe(Effect.exit)
@@ -340,7 +359,7 @@ describe("Plugin", () => {
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{ id: "rpc-plugin", version: "1", features: { rpc: true }, effect: () => Effect.void },
{ id: "rpc-plugin", revision: "1", features: { rpc: true }, effect: () => Effect.void },
])
expect(yield* plugins.list()).toEqual([
@@ -378,7 +397,7 @@ describe("Plugin", () => {
},
})
yield* plugins.activate([versioned(good), versioned(bad)])
yield* plugins.activate([generation(good), generation(bad)])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
@@ -396,7 +415,7 @@ describe("Plugin", () => {
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")])
yield* plugins.activate([generation(good), generation(bad, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
@@ -422,7 +441,7 @@ describe("Plugin", () => {
yield* plugins.activate([
{
id: "partial-tools",
version: "1",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.transform((draft) => {
@@ -488,8 +507,8 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
@@ -527,8 +546,8 @@ describe("Plugin", () => {
effect: () => Effect.die(new Error("replacement failed")),
})
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
@@ -549,7 +568,7 @@ describe("Plugin", () => {
yield* plugins.activate(
["first", "second"].map((id) => ({
id,
version: "1",
revision: "1",
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
})),
)
@@ -573,7 +592,7 @@ describe("Plugin", () => {
),
})
yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret"))
yield* plugins.activate([generation(plugin)]).pipe(Effect.provideService(Secret, "secret"))
expect(visible).toBe(false)
}),
@@ -586,7 +605,7 @@ describe("Plugin", () => {
yield* plugins.activate(
["a", "a:b", "雪"].map((id) => ({
id,
version: "1",
revision: "1",
effect: (context: EffectPlugin.Context) => Effect.sync(() => storage.set(id, context.storage)),
})),
)
@@ -648,7 +667,7 @@ describe("Plugin", () => {
.pipe(Effect.orDie),
})
yield* plugins.activate([versioned(plugin)])
yield* plugins.activate([generation(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
yield* plugins.activate([])
@@ -680,7 +699,7 @@ describe("Plugin", () => {
.pipe(Effect.orDie),
})
yield* plugins.activate([versioned(plugin)])
yield* plugins.activate([generation(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
"context7_look_up",
@@ -759,7 +778,7 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(plugin)])
yield* plugins.activate([generation(plugin)])
const toolSet = yield* registry.snapshot()
const execution = yield* toolSet.execute({
@@ -817,7 +836,7 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(plugin)])
yield* plugins.activate([generation(plugin)])
const toolSet = yield* registry.snapshot()
const failure = yield* toolSet
+2
View File
@@ -38,6 +38,8 @@ const npmLayer = Layer.succeed(
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
}),
)
+4 -1
View File
@@ -15,13 +15,15 @@ test("loads cached plugin packages without requesting a refresh", async () => {
add: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href, version: "1.2.3" }
}),
resolve: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
check: () => Effect.die(new Error("Unexpected check")),
update: () => Effect.die(new Error("Unexpected update")),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
@@ -30,5 +32,6 @@ test("loads cached plugin packages without requesting a refresh", async () => {
expect(plugin.id).toBe("config-effect-plugin")
expect(plugin.features).toEqual({ tui: true, rpc: true })
expect(plugin.source).toEqual({ type: "package", target: "fixture-plugin", version: "1.2.3" })
expect(calls).toEqual([{ subpaths: ["server", ""] }, { subpaths: ["tui"] }, { subpaths: ["rpc"] }])
})
+3 -2
View File
@@ -18,6 +18,7 @@ import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { Project } from "@opencode-ai/core/project"
import { Workspace } from "@opencode-ai/core/workspace"
import { codeModeListings } from "../lib/tool"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { Info } from "@opencode-ai/plugin/promise/tool"
@@ -167,7 +168,7 @@ describe("fromPromise", () => {
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
yield* plugins.activate([{ ...adapted, revision: "1" }])
}),
)
@@ -981,7 +982,7 @@ describe("fromPromise", () => {
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["acme.hello"])
expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual(["acme.hello"])
expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(
yield* snapshot.execute({
@@ -24,6 +24,8 @@ function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
})
}
@@ -16,6 +16,8 @@ const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
})
+4 -4
View File
@@ -39,7 +39,7 @@ it.effect("Effect plugins register, call, and publish RPCs independently of plug
yield* plugins.activate([
{
id: "implementer",
version: "1",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Echo, {
@@ -51,7 +51,7 @@ it.effect("Effect plugins register, call, and publish RPCs independently of plug
},
{
id: "consumer",
version: "1",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
@@ -78,7 +78,7 @@ it.effect("failed plugin setup removes RPC overrides and restores the previous i
yield* plugins.activate([
{
id: "implementer",
version: "1",
revision: "1",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
@@ -91,7 +91,7 @@ it.effect("failed plugin setup removes RPC overrides and restores the previous i
yield* plugins.activate([
{
id: "implementer",
version: "2",
revision: "2",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
@@ -84,7 +84,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
@@ -141,7 +141,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
@@ -197,7 +197,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins
.activate([{ ...adapted, version: "1" }])
.activate([{ ...adapted, revision: "1" }])
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
yield* plugins.activate([])
@@ -279,7 +279,7 @@ describe("Promise plugin RPC", () => {
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
const active = yield* Effect.promise(() => subscriptions.promise)
yield* plugins.activate([])

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