mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-28 19:34:43 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 870a10a7ba |
@@ -900,9 +900,6 @@
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@fontsource/commit-mono": "5.2.5",
|
||||
"@fontsource/noto-sans-math": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols-2": "5.2.5",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -1798,12 +1795,6 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@fontsource/noto-sans-math": ["@fontsource/noto-sans-math@5.2.5", "", {}, "sha512-1bxEvVlF51Vfgpju32mRZzI/CHvsfqjXjI2+sAuEyHYvXABUAIyj+93sCO3QZIoMG5drWyrzgoCqRQRaL6wQ8Q=="],
|
||||
|
||||
"@fontsource/noto-sans-symbols": ["@fontsource/noto-sans-symbols@5.2.5", "", {}, "sha512-mxoIRstsmZpZFzd/SRWiD+l6T7TGhpgCrGs7TEnnuGSQIfjVMrQT9Zej2enh9pkfmPNAFyeaGJkHkszJ1hH++w=="],
|
||||
|
||||
"@fontsource/noto-sans-symbols-2": ["@fontsource/noto-sans-symbols-2@5.2.5", "", {}, "sha512-F4O9WLifwoZS1quNzY1ebjMNo2cQPe/UP68Dmud0ONi2lOxaR6xp6fFPO2gG17MI7DwAnfMyQFl64A2tAd28hg=="],
|
||||
|
||||
"@fuma-translate/react": ["@fuma-translate/react@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw=="],
|
||||
|
||||
"@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-nF/DCAwOR21HZ4AkjIOv3Iqwyqywzb6pdyeMcoa+aZzirXj5ntvNZbe3jJ0v3ehhtrRfYYeXBezvjn8ZmV+fuQ=="],
|
||||
|
||||
@@ -703,14 +703,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
providerExecuted: block.type === "server_tool_use",
|
||||
}),
|
||||
},
|
||||
[
|
||||
...events,
|
||||
LLMEvent.toolInputStart({
|
||||
id: block.id ?? String(event.index),
|
||||
name: block.name ?? "",
|
||||
providerExecuted: block.type === "server_tool_use" ? true : undefined,
|
||||
}),
|
||||
],
|
||||
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -561,9 +561,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
hasToolCalls:
|
||||
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasToolCalls,
|
||||
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
|
||||
lifecycle,
|
||||
tools: result.tools,
|
||||
reasoningSignatures: Object.fromEntries(
|
||||
|
||||
@@ -464,7 +464,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
}
|
||||
|
||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||
// valid calls and malformed local calls settle independently.
|
||||
// JSON parse failures fail the stream at the boundary rather than at halt.
|
||||
const finished =
|
||||
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
||||
? yield* ToolStream.finishAll(ADAPTER, tools)
|
||||
|
||||
@@ -835,9 +835,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
hasFunctionCall:
|
||||
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasFunctionCall,
|
||||
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
|
||||
tools: result.tools,
|
||||
},
|
||||
events,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -53,7 +53,6 @@ const inputStart = (tool: PendingTool) =>
|
||||
LLMEvent.toolInputStart({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
})
|
||||
|
||||
@@ -64,36 +63,19 @@ const inputDelta = (tool: PendingTool, text: string) =>
|
||||
text,
|
||||
})
|
||||
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
const raw = inputOverride ?? tool.input
|
||||
return parseToolInput(route, tool.name, raw).pipe(
|
||||
Effect.map((input): ToolCall | ToolInputError =>
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
tool.providerExecuted
|
||||
? Effect.fail(error)
|
||||
: Effect.succeed(
|
||||
LLMEvent.toolInputError({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
raw,
|
||||
}),
|
||||
),
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
||||
Effect.map(
|
||||
(input): ToolCall =>
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
||||
event.type === "tool-input-error"
|
||||
? [event]
|
||||
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
|
||||
|
||||
/** Store the updated tool and produce the optional public delta event. */
|
||||
const appendTool = <K extends StreamKey>(
|
||||
@@ -176,9 +158,8 @@ export const appendExisting = <K extends StreamKey>(
|
||||
|
||||
/**
|
||||
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
||||
* from state, and return either a call or a non-executable local input error.
|
||||
* Missing keys are a no-op because some providers emit stop events for
|
||||
* non-tool content blocks.
|
||||
* from state, and return the optional public `tool-call` event. Missing keys are
|
||||
* a no-op because some providers emit stop events for non-tool content blocks.
|
||||
*/
|
||||
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -186,7 +167,10 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
|
||||
if (!tool) return { tools }
|
||||
return {
|
||||
tools: withoutTool(tools, key),
|
||||
events: finishEvents(tool, yield* toolCall(route, tool)),
|
||||
events: [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
yield* toolCall(route, tool),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -201,14 +185,17 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
|
||||
if (!tool) return { tools }
|
||||
return {
|
||||
tools: withoutTool(tools, key),
|
||||
events: finishEvents(tool, yield* toolCall(route, tool, input)),
|
||||
events: [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
yield* toolCall(route, tool, input),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
|
||||
* not emit per-tool stop events, so all accumulated calls finish independently
|
||||
* when the choice receives a terminal `finish_reason`.
|
||||
* not emit per-tool stop events, so all accumulated calls finish when the choice
|
||||
* receives a terminal `finish_reason`.
|
||||
*/
|
||||
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -218,7 +205,12 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
|
||||
return {
|
||||
tools: empty<K>(),
|
||||
events: yield* Effect.forEach(pending, (tool) =>
|
||||
toolCall(route, tool).pipe(Effect.map((event) => finishEvents(tool, event))),
|
||||
toolCall(route, tool).pipe(
|
||||
Effect.map((call) => [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
call,
|
||||
]),
|
||||
),
|
||||
).pipe(Effect.map((events) => events.flat())),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,7 +129,6 @@ export const ToolInputStart = Schema.Struct({
|
||||
type: Schema.tag("tool-input-start"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||
@@ -150,15 +149,6 @@ export const ToolInputEnd = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||
|
||||
/** A local tool call whose final input could not be decoded. */
|
||||
export const ToolInputError = Schema.Struct({
|
||||
type: Schema.tag("tool-input-error"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
raw: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
||||
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
|
||||
|
||||
export const ToolCall = Schema.Struct({
|
||||
type: Schema.tag("tool-call"),
|
||||
id: ToolCallID,
|
||||
@@ -226,7 +216,6 @@ const llmEventTagged = Schema.Union([
|
||||
ToolInputStart,
|
||||
ToolInputDelta,
|
||||
ToolInputEnd,
|
||||
ToolInputError,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
ToolError,
|
||||
@@ -264,8 +253,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputError: (input: WithID<ToolInputError, ToolCallID>) =>
|
||||
ToolInputError.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
||||
ToolResult.make({
|
||||
@@ -296,7 +283,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||
toolInputError: llmEventTagged.guards["tool-input-error"],
|
||||
toolCall: llmEventTagged.guards["tool-call"],
|
||||
toolResult: llmEventTagged.guards["tool-result"],
|
||||
toolError: llmEventTagged.guards["tool-error"],
|
||||
@@ -562,10 +548,6 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
||||
return reduceToolInputDelta(next, event)
|
||||
case "tool-input-end":
|
||||
return reduceToolInputEnd(next, event)
|
||||
case "tool-input-error": {
|
||||
const { [event.id]: _finished, ...toolInputs } = next.toolInputs
|
||||
return { ...next, toolInputs }
|
||||
}
|
||||
case "tool-call":
|
||||
return reduceToolCall(next, event)
|
||||
case "tool-result":
|
||||
|
||||
@@ -484,30 +484,6 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed server tool input terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "server_tool_use", id: "call_1", name: "web_search" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"partial' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
)
|
||||
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with a typed provider error for stream error frames", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -303,32 +303,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed tool input as an unexecuted tool error", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockStart",
|
||||
{
|
||||
contentBlockIndex: 0,
|
||||
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
|
||||
},
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
|
||||
id: "tool_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes reasoning deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -1259,69 +1259,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"partial',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
|
||||
type: "tool-input-error",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles malformed function arguments when output_item.added is absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"partial',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
|
||||
@@ -95,19 +95,4 @@ describe("LLMResponse reducer", () => {
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("clears malformed tool input without appending an executable call", () => {
|
||||
const state = reduce([
|
||||
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query":"partial' }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(state.toolInputs).toEqual({})
|
||||
expect(state.message.content).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,73 +64,6 @@ describe("ToolStream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes malformed local input as a non-executable tool error", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: '{"query":"partial',
|
||||
})
|
||||
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
|
||||
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
events: [
|
||||
{
|
||||
type: "tool-input-error",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves valid siblings when one parallel input is malformed", () =>
|
||||
Effect.gen(function* () {
|
||||
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||
id: "call_valid",
|
||||
name: "lookup",
|
||||
input: '{"query":"weather"}',
|
||||
})
|
||||
const tools = ToolStream.start(valid, 1, {
|
||||
id: "call_invalid",
|
||||
name: "lookup",
|
||||
input: '{"query":"partial',
|
||||
})
|
||||
const finished = yield* ToolStream.finishAll(ADAPTER, tools)
|
||||
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
events: [
|
||||
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
|
||||
{
|
||||
type: "tool-input-error",
|
||||
id: "call_invalid",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed provider-executed input terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||
id: "call_1",
|
||||
name: "web_search",
|
||||
input: '{"query":"partial',
|
||||
providerExecuted: true,
|
||||
})
|
||||
const result = yield* Effect.exit(ToolStream.finish(ADAPTER, tools, "item_1"))
|
||||
|
||||
expect(result._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves providerExecuted and clears all tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||
|
||||
@@ -1276,8 +1276,6 @@ export type SessionStepFailed = {
|
||||
error: SessionStructuredError
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
snapshot?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-24
@@ -30,7 +30,6 @@ import {
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
@@ -606,7 +605,6 @@ function streamPartEvents(
|
||||
LLMEvent.toolInputStart({
|
||||
id: event.id,
|
||||
name: event.toolName,
|
||||
providerExecuted: event.providerExecuted,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
])
|
||||
@@ -624,28 +622,15 @@ function streamPartEvents(
|
||||
])
|
||||
case "tool-call":
|
||||
state.toolNames[event.toolCallId] = event.toolName
|
||||
return ProviderShared.parseToolInput("aisdk", event.toolName, event.input).pipe(
|
||||
Effect.map((input) => [
|
||||
LLMEvent.toolCall({
|
||||
id: event.toolCallId,
|
||||
name: event.toolName,
|
||||
input,
|
||||
providerExecuted: event.providerExecuted,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
]),
|
||||
Effect.catch((error) =>
|
||||
event.providerExecuted
|
||||
? Effect.fail(error)
|
||||
: Effect.succeed([
|
||||
LLMEvent.toolInputError({
|
||||
id: event.toolCallId,
|
||||
name: event.toolName,
|
||||
raw: event.input,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
return Effect.succeed([
|
||||
LLMEvent.toolCall({
|
||||
id: event.toolCallId,
|
||||
name: event.toolName,
|
||||
input: parseToolInput(event.input),
|
||||
providerExecuted: event.providerExecuted,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
])
|
||||
case "tool-result":
|
||||
delete state.toolNames[event.toolCallId]
|
||||
return Effect.succeed([
|
||||
@@ -700,6 +685,14 @@ function providerMetadata(value: unknown) {
|
||||
return Schema.is(ProviderMetadata)(value) ? value : undefined
|
||||
}
|
||||
|
||||
function parseToolInput(value: string) {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function jsonObject(input: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonValue(value)]))
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@ export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
|
||||
}),
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||
}),
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -43,9 +40,6 @@ export class Remote extends Schema.Class<Remote>("ConfigV2.MCP.Remote")({
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||
}),
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
/**
|
||||
* Mutation paths do not accept project references. Relative paths resolve
|
||||
* from the active Location. Paths outside it require separate
|
||||
* Mutation paths do not accept project references. Relative paths must stay
|
||||
* inside the active Location. Absolute paths outside it require separate
|
||||
* `external_directory` approval.
|
||||
*/
|
||||
export const ResolveInput = Schema.Struct({
|
||||
@@ -25,7 +25,7 @@ export type ResolveInput = typeof ResolveInput.Type
|
||||
|
||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||
path: Schema.String,
|
||||
reason: Schema.Literals(["location_escape", "non_directory_ancestor"]),
|
||||
reason: Schema.Literals(["relative_escape", "location_escape", "non_directory_ancestor"]),
|
||||
}) {}
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
@@ -53,9 +53,9 @@ export interface Target {
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Resolve a path and derive its permission resources. Relative paths resolve
|
||||
* from the Location. Paths outside it require separate `external_directory`
|
||||
* approval. This does not approve the mutation.
|
||||
* Resolve a path and derive its permission resources. Relative paths must
|
||||
* stay inside the Location. Absolute paths outside it require separate
|
||||
* `external_directory` approval. This does not approve the mutation.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
||||
}
|
||||
@@ -120,8 +120,10 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
const relative = !path.isAbsolute(input.path)
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||
if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" })
|
||||
|
||||
const resolved = yield* resolvePath(absolute)
|
||||
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
|
||||
@@ -144,7 +146,10 @@ const layer = Layer.effect(
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||
path.join(
|
||||
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
|
||||
"*",
|
||||
),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -42,7 +42,6 @@ export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.Se
|
||||
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
codemode: Schema.Boolean.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
inputSchema: Schema.Unknown.pipe(Schema.optional),
|
||||
outputSchema: Schema.Unknown.pipe(Schema.optional),
|
||||
@@ -210,12 +209,7 @@ export const layer = Layer.effect(
|
||||
entry.integrationID = integrationID
|
||||
owned.add(integrationID)
|
||||
const methodID = Integration.MethodID.make(suffix)
|
||||
// Each registration gets its own child scope so disposal detaches it from the root scope
|
||||
// entirely; registering directly on root would accumulate a dead finalizer per replaced or
|
||||
// removed server for the lifetime of the layer.
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.registration = { dispose: Scope.close(scope, Exit.void) }
|
||||
yield* integration
|
||||
entry.registration = yield* integration
|
||||
.transform((draft) => {
|
||||
draft.update(integrationID, (ref) => {
|
||||
ref.name = name
|
||||
@@ -226,7 +220,7 @@ export const layer = Layer.effect(
|
||||
authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }),
|
||||
})
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
.pipe(Scope.provide(root))
|
||||
})
|
||||
yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true })
|
||||
|
||||
@@ -368,11 +362,10 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
} satisfies MCPClient.ElicitationHandler
|
||||
|
||||
const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
|
||||
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
||||
new Tool({
|
||||
server,
|
||||
name: def.name,
|
||||
codemode: entry.config.codemode,
|
||||
description: def.description,
|
||||
inputSchema: def.inputSchema,
|
||||
outputSchema: def.outputSchema,
|
||||
@@ -414,7 +407,7 @@ export const layer = Layer.effect(
|
||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
connection.tools().pipe(
|
||||
Effect.map((defs) => {
|
||||
entry.tools = defs.map((def) => toTool(name, entry, def))
|
||||
entry.tools = defs.map((def) => toTool(name, def))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -505,7 +498,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
if (Exit.isSuccess(result)) {
|
||||
entry.client = result.value.connection
|
||||
entry.tools = result.value.tools.map((def) => toTool(name, entry, def))
|
||||
entry.tools = result.value.tools.map((def) => toTool(name, def))
|
||||
entry.prompts = []
|
||||
entry.status = { status: "connected" }
|
||||
watch(name, entry, result.value.connection)
|
||||
|
||||
@@ -297,12 +297,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = castDraft(event.data.tokens)
|
||||
}
|
||||
if (event.data.snapshot || event.data.files)
|
||||
draft.snapshot = {
|
||||
...draft.snapshot,
|
||||
end: event.data.snapshot,
|
||||
files: event.data.files ? Array.from(event.data.files) : undefined,
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.text.started": (event) => {
|
||||
|
||||
@@ -143,10 +143,6 @@ const layer = Layer.effect(
|
||||
}
|
||||
}
|
||||
yield* publish(event)
|
||||
if (LLMEvent.is.toolInputError(event)) {
|
||||
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
|
||||
return
|
||||
}
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
const tool = prepared.resolveToolCall(event.name)
|
||||
if (tool.type === "reject") {
|
||||
@@ -199,27 +195,25 @@ const layer = Layer.effect(
|
||||
tokens: settlement.tokens,
|
||||
})
|
||||
|
||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||
const snapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && snapshot
|
||||
? yield* snapshots
|
||||
.files({ from: startSnapshot, to: snapshot })
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
return { snapshot, files }
|
||||
})
|
||||
|
||||
// Captures the end snapshot, diffs it against the step's start, and durably ends the
|
||||
// assistant step.
|
||||
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
||||
Effect.gen(function* () {
|
||||
const end = yield* captureStepEnd()
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
? yield* snapshots
|
||||
.files({ from: startSnapshot, to: endSnapshot })
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
yield* serialized(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: settlement.finish,
|
||||
...stepUsage(settlement),
|
||||
...end,
|
||||
snapshot: endSnapshot,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -330,15 +324,8 @@ const layer = Layer.effect(
|
||||
const stepFailure = publisher.stepFailure()
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
|
||||
if (stepFailure) {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* serialized(
|
||||
publisher.publishStepFailure({
|
||||
...(stepSettlement ? stepUsage(stepSettlement) : {}),
|
||||
...end,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (stepFailure)
|
||||
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (userDeclined) return yield* Effect.interrupt
|
||||
@@ -391,11 +378,7 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
||||
}),
|
||||
)
|
||||
if (attempt._tag === "Completed")
|
||||
return {
|
||||
needsContinuation: attempt.needsContinuation,
|
||||
step: attempt.step,
|
||||
}
|
||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||
yield* Effect.yieldNow
|
||||
currentPromotion = undefined
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { RelativePath } from "../../schema"
|
||||
import { SessionUsage } from "../usage"
|
||||
|
||||
type Input = {
|
||||
@@ -113,12 +112,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
if (state !== undefined) current.state = { ...current.state, ...state }
|
||||
return Effect.succeed(current.ordinal)
|
||||
})
|
||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) {
|
||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
||||
yield* ended(
|
||||
id,
|
||||
value ?? current.values.join(""),
|
||||
current.values.join(""),
|
||||
current.ordinal,
|
||||
state === undefined ? current.state : { ...current.state, ...state },
|
||||
)
|
||||
@@ -176,11 +175,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
yield* toolInput.flush()
|
||||
})
|
||||
|
||||
const startToolInput = Effect.fnUntraced(function* (event: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly providerExecuted?: boolean
|
||||
}) {
|
||||
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
||||
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
tools.set(event.id, {
|
||||
@@ -188,7 +183,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
name: event.name,
|
||||
called: false,
|
||||
settled: false,
|
||||
providerExecuted: event.providerExecuted === true,
|
||||
providerExecuted: false,
|
||||
})
|
||||
yield* toolInput.start(event.id)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
@@ -199,41 +194,13 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
})
|
||||
})
|
||||
|
||||
const endToolInput = Effect.fnUntraced(function* (
|
||||
event: { readonly id: string; readonly name: string },
|
||||
value?: string,
|
||||
) {
|
||||
const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`))
|
||||
yield* toolInput.end(event.id, undefined, value)
|
||||
})
|
||||
|
||||
const failMalformedToolInput = Effect.fnUntraced(function* (event: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly raw: string
|
||||
}) {
|
||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool || tool.called || tool.settled)
|
||||
return yield* Effect.die(new Error(`Malformed tool input after call settlement: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||
if (toolInput.has(event.id)) yield* endToolInput(event, event.raw)
|
||||
tool.settled = true
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: {
|
||||
type: "tool.input-json",
|
||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||
},
|
||||
executed: false,
|
||||
})
|
||||
yield* toolInput.end(event.id)
|
||||
})
|
||||
|
||||
const flush = Effect.fn("SessionRunner.flush")(function* () {
|
||||
@@ -262,18 +229,16 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return failed
|
||||
})
|
||||
|
||||
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error) {
|
||||
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
|
||||
yield* flush()
|
||||
yield* failTools(error, "uncalled")
|
||||
yield* startAssistant()
|
||||
if (stepFailure === undefined) stepFailure = error
|
||||
if (replace || stepFailure === undefined) stepFailure = error
|
||||
})
|
||||
|
||||
const publishStepFailure = Effect.fnUntraced(function* (details?: {
|
||||
readonly cost?: Money.USD
|
||||
readonly tokens?: ReturnType<typeof SessionUsage.tokens>
|
||||
readonly snapshot?: Snapshot.ID
|
||||
readonly files?: readonly RelativePath[]
|
||||
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
|
||||
readonly cost: Money.USD
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}) {
|
||||
if (stepFailed || stepFailure === undefined) return
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
@@ -282,7 +247,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
error: stepFailure,
|
||||
...details,
|
||||
...usage,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -372,10 +337,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
case "tool-input-end":
|
||||
yield* endToolInput(event)
|
||||
return
|
||||
case "tool-input-error":
|
||||
retryEvidence = true
|
||||
yield* failMalformedToolInput(event)
|
||||
return
|
||||
case "tool-call": {
|
||||
retryEvidence = true
|
||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||
@@ -458,7 +419,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
|
||||
if (event.reason === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
|
||||
return
|
||||
}
|
||||
return
|
||||
@@ -466,7 +427,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message })
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
@@ -136,19 +136,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
||||
: item.text.length > 0
|
||||
? [{ type: "text", text: item.text }]
|
||||
: []
|
||||
const reuseToolProviderMetadata =
|
||||
reuseProviderMetadata ||
|
||||
(sameModel &&
|
||||
item.executed === true &&
|
||||
(item.state.status === "completed" || (item.state.status === "error" && item.state.result !== undefined)))
|
||||
const call = toolCall(
|
||||
item,
|
||||
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseToolProviderMetadata
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ import { ToolRegistry } from "./registry"
|
||||
* Registry namespace and permission action names for MCP tools.
|
||||
*/
|
||||
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||
export const name = (server: string, tool: string) =>
|
||||
`${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
@@ -32,11 +33,11 @@ export const layer = Layer.effectDiscard(
|
||||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const groups = new Map<string, { tools: Record<string, Tool.AnyTool>; codemode: boolean }>()
|
||||
const groups = new Map<string, Record<string, Tool.AnyTool>>()
|
||||
for (const tool of yield* mcp.tools()) {
|
||||
const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false }
|
||||
const group = groups.get(tool.server) ?? {}
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
group.tools[tool.name] = Tool.withPermission(
|
||||
group[tool.name] = Tool.withPermission(
|
||||
Tool.make({
|
||||
description: tool.description ?? "",
|
||||
jsonSchema: {
|
||||
@@ -107,7 +108,7 @@ export const layer = Layer.effectDiscard(
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* Effect.forEach(
|
||||
groups,
|
||||
([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }),
|
||||
([server, record]) => tools.register(record, { namespace: namespace(server) }),
|
||||
{
|
||||
discard: true,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { LLM, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient } from "@opencode-ai/ai/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AISDK.locationLayer)
|
||||
@@ -19,37 +19,6 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
provider: "test",
|
||||
modelId: "test",
|
||||
supportedUrls: {},
|
||||
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
||||
doStream: () =>
|
||||
Promise.resolve({
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
events.forEach((event) => controller.enqueue(event))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const usage = {
|
||||
inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
outputTokens: { total: 1, text: 0, reasoning: 0 },
|
||||
} as const
|
||||
|
||||
const client = LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keys language models by package and flattened overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
@@ -269,68 +238,3 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const raw = '{"query":"partial'
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = {
|
||||
languageModel: () =>
|
||||
streamModel([
|
||||
{ type: "tool-input-start", id: "call_1", toolName: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", delta: raw },
|
||||
{ type: "tool-input-end", id: "call_1" },
|
||||
{ type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: raw },
|
||||
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage },
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe(
|
||||
Effect.provide(client),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw,
|
||||
})
|
||||
expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const raw = '{"query":"partial'
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = {
|
||||
languageModel: () =>
|
||||
streamModel([
|
||||
{ type: "tool-input-start", id: "call_1", toolName: "web_search", providerExecuted: true },
|
||||
{ type: "tool-input-delta", id: "call_1", delta: raw },
|
||||
{ type: "tool-input-end", id: "call_1" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "web_search",
|
||||
input: raw,
|
||||
providerExecuted: true,
|
||||
},
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("hosted-test-ai-sdk"))
|
||||
const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Search" })).pipe(
|
||||
Effect.provide(client),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -655,7 +655,6 @@ describe("Config", () => {
|
||||
command: ["node", "./mcp/server.js"],
|
||||
environment: { API_KEY: "secret" },
|
||||
disabled: false,
|
||||
codemode: false,
|
||||
timeout: { catalog: 10000 },
|
||||
},
|
||||
remote: {
|
||||
@@ -664,7 +663,6 @@ describe("Config", () => {
|
||||
headers: { Authorization: "Bearer token" },
|
||||
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
|
||||
disabled: true,
|
||||
codemode: false,
|
||||
timeout: { startup: 15000 },
|
||||
},
|
||||
},
|
||||
@@ -742,7 +740,6 @@ describe("Config", () => {
|
||||
command: ["node", "./mcp/server.js"],
|
||||
environment: { API_KEY: "secret" },
|
||||
disabled: false,
|
||||
codemode: false,
|
||||
timeout: { catalog: 10000 },
|
||||
},
|
||||
remote: {
|
||||
@@ -751,7 +748,6 @@ describe("Config", () => {
|
||||
headers: { Authorization: "Bearer token" },
|
||||
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
|
||||
disabled: true,
|
||||
codemode: false,
|
||||
timeout: { startup: 15000 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -60,19 +60,11 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("requires external-directory authorization for a relative lexical escape", () =>
|
||||
it.live("rejects a relative lexical escape instead of promoting it to external authority", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||
const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory)))
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(root, "outside.txt"),
|
||||
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
|
||||
})
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: root,
|
||||
resource: path.join(root, "*").replaceAll("\\", "/"),
|
||||
})
|
||||
const error = yield* Effect.flip((yield* LocationMutation.Service).resolve({ path: "../outside.txt" }))
|
||||
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "relative_escape" })
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -232,13 +232,6 @@ const mcp = Layer.mock(MCP.Service, {
|
||||
required: ["ok"],
|
||||
},
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
name: "lookup",
|
||||
codemode: false,
|
||||
description: "Lookup",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
]),
|
||||
callTool: (input) =>
|
||||
Effect.sync(() => {
|
||||
@@ -773,18 +766,6 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "direct_lookup")
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const execute = definitions.find((tool) => tool.name === "execute")
|
||||
|
||||
expect(definitions.some((tool) => tool.name === "direct_lookup")).toBe(true)
|
||||
expect(execute?.description).not.toContain("tools.direct.lookup")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for permission before calling an MCP tool", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
|
||||
@@ -565,22 +565,6 @@ Recent work
|
||||
text: "Partial thought",
|
||||
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-completed",
|
||||
name: "web_search",
|
||||
executed: true,
|
||||
providerState: { itemId: "call_completed" },
|
||||
providerResultState: { itemId: "result_completed" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [],
|
||||
structured: {},
|
||||
result: { type: "json", value: { found: true } },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
@@ -608,22 +592,6 @@ Recent work
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Partial thought" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-completed",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { provider: { itemId: "call_completed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-completed",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { found: true } },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { provider: { itemId: "result_completed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-failed",
|
||||
|
||||
@@ -9,8 +9,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_tool_event_test")
|
||||
@@ -231,8 +229,6 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
publisher.publishStepFailure({
|
||||
cost: Money.USD.make(1.25),
|
||||
tokens: settlement.tokens,
|
||||
snapshot: Snapshot.ID.make("tree-end"),
|
||||
files: [RelativePath.make("src/changed.ts")],
|
||||
}),
|
||||
)
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
|
||||
@@ -240,8 +236,6 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
cost: 1.25,
|
||||
tokens: { input: 8, output: 2, reasoning: 1 },
|
||||
snapshot: "tree-end",
|
||||
files: ["src/changed.ts"],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4164,320 +4164,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues after malformed local tool input without exposing raw arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Recover malformed tool input")
|
||||
const marker = "raw-malformed-marker"
|
||||
const raw = `{"text":"${marker}`
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: raw }),
|
||||
LLMEvent.toolInputEnd({ id: "call-malformed", name: "echo" }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw,
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(executions).toEqual([])
|
||||
expect(JSON.stringify(requests[1])).not.toContain(marker)
|
||||
expect(requests[1]?.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "tool-call", id: "call-malformed", name: "echo", input: {} }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "tool",
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "tool-result",
|
||||
id: "call-malformed",
|
||||
result: expect.objectContaining({
|
||||
type: "error",
|
||||
value: expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
const context = yield* session.context(sessionID)
|
||||
const failed = context.find(
|
||||
(message): message is SessionMessage.Assistant =>
|
||||
message.type === "assistant" && message.content.some((item) => item.type === "tool"),
|
||||
)
|
||||
expect(failed).toMatchObject({
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-malformed",
|
||||
executed: false,
|
||||
state: {
|
||||
status: "error",
|
||||
input: {},
|
||||
error: {
|
||||
type: "tool.input-json",
|
||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
if (!failed) throw new Error("Malformed tool assistant missing")
|
||||
expect(failed.error).toBeUndefined()
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.failed.1",
|
||||
"session.step.ended.1",
|
||||
])
|
||||
const database = (yield* Database.Service).db
|
||||
const durable = yield* database
|
||||
.select({ type: EventTable.type, data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(durable.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
|
||||
callID: "call-malformed",
|
||||
text: raw,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles a valid sibling before recovering malformed tool input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Run parallel tools")
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "valid" } }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(executions).toEqual(["valid"])
|
||||
const request = requests[1]
|
||||
if (!request) throw new Error("Malformed recovery request missing")
|
||||
expect(request.messages.flatMap((message) => (message.role === "tool" ? message.content : []))).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "call-valid", type: "tool-result" }),
|
||||
expect.objectContaining({ id: "call-malformed", type: "tool-result" }),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover malformed input after sibling execution is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Interrupt malformed recovery")
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "blocked" } }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
while (
|
||||
!(yield* session.context(sessionID)).some(
|
||||
(message) =>
|
||||
message.type === "assistant" &&
|
||||
message.content.some((item) => item.type === "tool" && item.id === "call-malformed"),
|
||||
)
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* session.interrupt(sessionID)
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Interrupt malformed recovery" },
|
||||
{
|
||||
type: "assistant",
|
||||
error: { type: "aborted", message: "Step interrupted" },
|
||||
content: [
|
||||
{ type: "tool", id: "call-valid", state: { status: "error", error: { type: "aborted" } } },
|
||||
{ type: "tool", id: "call-malformed", state: { status: "error" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("records malformed provider-executed input as executed", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail malformed hosted input")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Invalid hosted tool input" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-hosted", name: "web_search", providerExecuted: true }),
|
||||
LLMEvent.toolInputDelta({ id: "call-hosted", name: "web_search", text: '{"query":"partial' }),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
error: { type: "provider.invalid-output", message: "Invalid hosted tool input" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-hosted",
|
||||
executed: true,
|
||||
state: { status: "error", error: { type: "provider.invalid-output" } },
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("records a provider failure after malformed input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail after malformed input")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Provider failed after malformed input" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
error: { type: "provider.invalid-output", message: "Provider failed after malformed input" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-malformed",
|
||||
executed: false,
|
||||
state: { status: "error", error: { type: "tool.input-json" } },
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues after repeated malformed tool input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Keep producing malformed tools")
|
||||
const malformed = (id: string) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputError({
|
||||
id,
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]
|
||||
responses = [
|
||||
malformed("call-first"),
|
||||
reply.tool("call-valid-between", "echo", { text: "valid" }),
|
||||
malformed("call-second"),
|
||||
reply.stop(),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(4)
|
||||
expect(executions).toEqual(["valid"])
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.failed.1")).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue malformed tool input past the agent step limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.steps = 2
|
||||
}),
|
||||
)
|
||||
yield* admit(session, "Stop malformed tools at the step limit")
|
||||
const malformed = (id: string) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputError({
|
||||
id,
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]
|
||||
responses = [malformed("call-first"), malformed("call-at-limit")]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.1")).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -4569,24 +4255,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the provider failure when tool output persistence also fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Storage fails while provider fails")
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-store-provider-error", name: "storefail", input: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
|
||||
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
error: { type: "provider.unknown", message: "Provider unavailable" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -277,38 +277,6 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves a relative external target before reading update content", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const target = path.join(outside.path, "external.txt")
|
||||
const relative = path.relative(active.path, target)
|
||||
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${relative}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(readsBeforeEditApproval).toBe(0)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves one external directory scope for multiple files under the same parent", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
|
||||
@@ -75,7 +75,6 @@ A local server is a command that OpenCode starts using the MCP stdio transport.
|
||||
| `cwd` | No | Process working directory. Relative paths resolve from the workspace directory; the workspace is the default. |
|
||||
| `environment` | No | String environment variables added to the inherited OpenCode process environment. |
|
||||
| `disabled` | No | Set to `true` to prevent the server from connecting. Defaults to `false`. |
|
||||
| `codemode` | No | Set to `false` to expose the server's tools directly to the model instead of through Code Mode. Defaults to `true`. |
|
||||
| `timeout` | No | Per-server timeout overrides. |
|
||||
|
||||
Use `{env:NAME}` to substitute an environment variable while loading config. Shell expressions such as `$NAME` are not expanded in JSON strings.
|
||||
@@ -109,7 +108,6 @@ A remote server uses the MCP Streamable HTTP transport. Its `url` must be a vali
|
||||
| `headers` | No | String HTTP headers sent to the MCP endpoint. |
|
||||
| `oauth` | No | OAuth client settings, or `false` to disable OAuth support. |
|
||||
| `disabled` | No | Set to `true` to prevent the server from connecting. Defaults to `false`. |
|
||||
| `codemode` | No | Set to `false` to expose the server's tools directly to the model instead of through Code Mode. Defaults to `true`. |
|
||||
| `timeout` | No | Per-server timeout overrides. |
|
||||
|
||||
Use `oauth: false` for a server that exclusively uses an API key or another header-based credential.
|
||||
@@ -225,22 +223,6 @@ OpenCode combines the server name and MCP tool name as `<server>_<tool>`. Charac
|
||||
|
||||
Choose short server names that remain unique after normalization. Under the default Code Mode, MCP tools are grouped by the normalized server name.
|
||||
|
||||
Set `codemode` to `false` on a server when its tools should remain on the provider's native tool list:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"codemode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use permission actions to hide or deny a server's tools without stopping its connection:
|
||||
|
||||
```jsonc
|
||||
|
||||
@@ -276,8 +276,6 @@ export namespace Step {
|
||||
error: SessionError.Error,
|
||||
cost: Money.USD.pipe(optional),
|
||||
tokens: TokenUsage.Info.pipe(optional),
|
||||
snapshot: Snapshot.ID.pipe(optional),
|
||||
files: Schema.Array(RelativePath).pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Failed = typeof Failed.Type
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/commit-mono": "5.2.5",
|
||||
"@fontsource/noto-sans-math": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols-2": "5.2.5",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
|
||||
@@ -5,29 +5,16 @@ import regularFont from "@fontsource/commit-mono/files/commit-mono-latin-400-nor
|
||||
import boldFont from "@fontsource/commit-mono/files/commit-mono-latin-700-normal.woff2" with { type: "file" }
|
||||
import italicFont from "@fontsource/commit-mono/files/commit-mono-latin-400-italic.woff2" with { type: "file" }
|
||||
import boldItalicFont from "@fontsource/commit-mono/files/commit-mono-latin-700-italic.woff2" with { type: "file" }
|
||||
import symbolFont from "@fontsource/noto-sans-symbols/files/noto-sans-symbols-symbols-400-normal.woff2" with { type: "file" }
|
||||
import symbolFont2 from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-symbols-400-normal.woff2" with { type: "file" }
|
||||
import brailleFont from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-braille-400-normal.woff2" with { type: "file" }
|
||||
import mathFont from "@fontsource/noto-sans-math/files/noto-sans-math-math-400-normal.woff2" with { type: "file" }
|
||||
|
||||
const CellWidth = 10
|
||||
const CellHeight = 20
|
||||
const FontSize = 16
|
||||
const FontFamily = "OpenCode Mono"
|
||||
const SymbolFontFamily = "OpenCode Symbols"
|
||||
const SymbolFontFamily2 = "OpenCode Symbols 2"
|
||||
const MathFontFamily = "OpenCode Math"
|
||||
const FontStack = `"${FontFamily}", "${SymbolFontFamily}", "${SymbolFontFamily2}", "${MathFontFamily}"`
|
||||
|
||||
for (const [file, family] of [
|
||||
...[regularFont, boldFont, italicFont, boldItalicFont].map((file) => [file, FontFamily] as const),
|
||||
[symbolFont, SymbolFontFamily],
|
||||
[symbolFont2, SymbolFontFamily2],
|
||||
[brailleFont, SymbolFontFamily2],
|
||||
[mathFont, MathFontFamily],
|
||||
] as const) {
|
||||
for (const file of [regularFont, boldFont, italicFont, boldItalicFont]) {
|
||||
const font = Buffer.from(await Bun.file(file).arrayBuffer())
|
||||
if (!GlobalFonts.register(font, family)) throw new Error(`Failed to register screenshot font: ${file}`)
|
||||
if (!GlobalFonts.register(font, FontFamily))
|
||||
throw new Error(`Failed to register screenshot font: ${file}`)
|
||||
}
|
||||
|
||||
export function screenshot(renderer: CliRenderer) {
|
||||
@@ -68,7 +55,7 @@ export function screenshotFrame(frame: CapturedFrame) {
|
||||
const x = column * CellWidth
|
||||
const y = row * CellHeight
|
||||
if (!drawBlockElement(context, char, x, y, cells)) {
|
||||
context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px ${FontStack}`
|
||||
context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"`
|
||||
context.fillText(char, x, y + 1)
|
||||
}
|
||||
if (attributes & TextAttributes.UNDERLINE) {
|
||||
|
||||
@@ -29,38 +29,6 @@ test("renders captured frames with bundled fonts", () => {
|
||||
expect(image.data.subarray(1, 4).toString()).toBe("PNG")
|
||||
})
|
||||
|
||||
test("renders OpenCode symbols instead of missing-glyph boxes", async () => {
|
||||
const pixels = async (text: string) => {
|
||||
const image = SimulationPng.screenshotFrame({
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
cursor: [0, 0],
|
||||
lines: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
text,
|
||||
width: 1,
|
||||
fg: RGBA.fromInts(255, 255, 255),
|
||||
bg: RGBA.fromInts(0, 0, 0),
|
||||
attributes: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const canvas = createCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")
|
||||
context.drawImage(await loadImage(image.data), 0, 0)
|
||||
return [...context.getImageData(0, 0, image.width, image.height).data]
|
||||
}
|
||||
|
||||
const missingGlyph = await pixels("\u{10ffff}")
|
||||
for (const symbol of ["△", "✱", "⇆", "⠹"]) {
|
||||
expect(await pixels(symbol)).not.toEqual(missingGlyph)
|
||||
}
|
||||
})
|
||||
|
||||
test("fills adjacent block elements without glyph gaps", async () => {
|
||||
const image = SimulationPng.screenshotFrame({
|
||||
cols: 2,
|
||||
@@ -129,5 +97,7 @@ test("draws heavy vertical box elements on cell boundaries", async () => {
|
||||
expect([...context.getImageData(4, 0, 2, 30).data]).toEqual(
|
||||
Array.from({ length: 60 }, () => [255, 255, 255, 255]).flat(),
|
||||
)
|
||||
expect([...context.getImageData(4, 30, 2, 10).data]).toEqual(Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat())
|
||||
expect([...context.getImageData(4, 30, 2, 10).data]).toEqual(
|
||||
Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat(),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ import { useConfig } from "../../config"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { collapseToolOutput, normalizeShellOutput } from "../../util/collapse-tool-output"
|
||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
import { usePluginRuntime } from "../../plugin/runtime"
|
||||
import { PluginSlot } from "../../plugin/context"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
@@ -111,7 +111,6 @@ export function Session() {
|
||||
const route = useRouteData("session")
|
||||
const { navigate } = useRoute()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const paths = useTuiPaths()
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
@@ -185,24 +184,6 @@ export function Session() {
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
const toast = useToast()
|
||||
const client = useClient()
|
||||
const autoApproved = new Set<string>()
|
||||
createEffect(() => {
|
||||
if (local.permission.mode !== "auto") return
|
||||
permissions().forEach((request) => {
|
||||
if (autoApproved.has(request.id)) return
|
||||
autoApproved.add(request.id)
|
||||
void client.api.permission
|
||||
.reply({
|
||||
sessionID: request.sessionID,
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
.catch((error) => {
|
||||
autoApproved.delete(request.id)
|
||||
toast.error(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
const editor = useEditorContext()
|
||||
const rows = createSessionRows(() => route.sessionID)
|
||||
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
||||
@@ -2448,7 +2429,7 @@ function Shell(props: ToolProps) {
|
||||
limit: 1024 * 1024,
|
||||
location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined,
|
||||
})
|
||||
.then((response) => setBackgroundOutput(normalizeShellOutput(response.data.output.trim())))
|
||||
.then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim())))
|
||||
.catch(() => undefined)
|
||||
loading = false
|
||||
}
|
||||
@@ -2461,7 +2442,7 @@ function Shell(props: ToolProps) {
|
||||
if (props.part.state.status === "streaming") return ""
|
||||
if (shellID()) return expanded() ? backgroundOutput() : ""
|
||||
const content = props.part.state.content[0]
|
||||
return normalizeShellOutput(content?.type === "text" ? content.text.trim() : "")
|
||||
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
|
||||
})
|
||||
const maxLines = 10
|
||||
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
||||
|
||||
@@ -283,7 +283,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setTimeout(() => {
|
||||
if (filter.length > 0) {
|
||||
moveTo(0, true, false)
|
||||
} else if (current && props.focusCurrent !== false) {
|
||||
} else if (current && props.focusCurrent !== false && store.input !== "mouse") {
|
||||
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
|
||||
if (currentIndex >= 0) {
|
||||
moveTo(currentIndex, true)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import stripAnsi from "strip-ansi"
|
||||
|
||||
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
|
||||
const lines = output.split("\n")
|
||||
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
|
||||
@@ -21,7 +19,3 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
|
||||
|
||||
return { output: preview, overflow: true }
|
||||
}
|
||||
|
||||
export function normalizeShellOutput(output: string) {
|
||||
return stripAnsi(output).replace(/\r\n?/g, "\n")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { collapseToolOutput, normalizeShellOutput } from "../../../src/util/collapse-tool-output"
|
||||
import { collapseToolOutput } from "../../../src/util/collapse-tool-output"
|
||||
|
||||
test("limits command input and output to the same line budget", () => {
|
||||
const command = Array.from({ length: 8 }, (_, index) => `command ${index + 1}`).join("\n")
|
||||
@@ -12,13 +12,3 @@ test("limits command input and output to the same line budget", () => {
|
||||
expect(collapsed.output).toContain("command 8\n\noutput 1…")
|
||||
expect(collapsed.output).not.toContain("output 2")
|
||||
})
|
||||
|
||||
test("normalizes carriage-return shell progress before collapsing", () => {
|
||||
const output = Array.from({ length: 30 }, (_, index) => `progress ${index + 1}`).join("\r")
|
||||
const collapsed = collapseToolOutput(normalizeShellOutput(output), 10, 1_000)
|
||||
|
||||
expect(collapsed.overflow).toBe(true)
|
||||
expect(collapsed.output.split("\n")).toHaveLength(10)
|
||||
expect(collapsed.output).toContain("progress 10…")
|
||||
expect(collapsed.output).not.toContain("progress 11")
|
||||
})
|
||||
|
||||
@@ -79,7 +79,7 @@ async function renderSelect(
|
||||
return app
|
||||
}
|
||||
|
||||
async function mountSelect(root: string, initial: DialogSelectOption<string>[]) {
|
||||
async function mountSelect(root: string, initial: DialogSelectOption<string>[], trackCurrent = false) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
const config = createTuiResolvedConfig()
|
||||
@@ -105,6 +105,7 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
|
||||
|
||||
function Harness() {
|
||||
const [options, setOptions] = createSignal(initial)
|
||||
const [current, setCurrent] = createSignal(initial[0]?.value)
|
||||
replaceOptions = setOptions
|
||||
|
||||
function Fixture() {
|
||||
@@ -114,7 +115,11 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
|
||||
<DialogSelect
|
||||
title="Mutable options"
|
||||
options={options()}
|
||||
onMove={(option) => moved.push(option.value)}
|
||||
current={trackCurrent ? current() : undefined}
|
||||
onMove={(option) => {
|
||||
moved.push(option.value)
|
||||
if (trackCurrent) setCurrent(option.value)
|
||||
}}
|
||||
onSelect={(option) => selected.push(option.value)}
|
||||
/>
|
||||
)),
|
||||
@@ -272,3 +277,30 @@ test("selects a repopulated option after removing the only option", async () =>
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("hover stays on the row when current follows selection", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mountSelect(
|
||||
tmp.path,
|
||||
["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth"].map((value) => ({
|
||||
title: value,
|
||||
value,
|
||||
})),
|
||||
true,
|
||||
)
|
||||
|
||||
try {
|
||||
await select.app.mockMouse.moveTo(20, 11)
|
||||
await select.app.flush()
|
||||
await select.app.mockMouse.moveTo(20, 12)
|
||||
await select.app.waitFor(() => select.moved.includes("second"))
|
||||
select.moved.length = 0
|
||||
await select.app.mockMouse.moveTo(20, 14)
|
||||
await select.app.waitFor(() => select.moved.length > 0)
|
||||
await select.app.waitForVisualIdle()
|
||||
|
||||
expect(select.moved).toEqual(["fourth"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -385,8 +385,8 @@ Affected schema:
|
||||
|
||||
Change:
|
||||
|
||||
- Resolve relative mutation paths from the active Location.
|
||||
- Require explicit `external_directory` approval before leaf approval for external paths.
|
||||
- Resolve relative mutation paths within the active Location.
|
||||
- Accept absolute internal paths and require explicit `external_directory` approval before leaf approval for external absolute paths.
|
||||
- Keep named references read-oriented and reject them for mutation.
|
||||
- Revalidate path authority immediately before write mechanics.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user