mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 03:36:10 +00:00
Compare commits
5
Commits
write-preview
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7456598cde | ||
|
|
c228fc4886 | ||
|
|
bb3f4cc3c7 | ||
|
|
8d9727be9f | ||
|
|
6401eeaea0 |
@@ -78,7 +78,10 @@ const streamText = LLM.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
|
||||
if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
|
||||
if (event.type === "finish")
|
||||
process.stdout.write(
|
||||
`\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`,
|
||||
)
|
||||
}),
|
||||
),
|
||||
Stream.runDrain,
|
||||
@@ -194,7 +197,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
||||
event: Schema.String,
|
||||
initial: () => undefined,
|
||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
||||
onHalt: () => [{ type: "finish", reason: "stop" }],
|
||||
onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -601,7 +601,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
// =============================================================================
|
||||
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
|
||||
if (reason === "max_tokens") return "length"
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
if (reason === "refusal") return "content-filter"
|
||||
return "unknown"
|
||||
@@ -836,7 +836,10 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: mapFinishReason(event.delta?.stop_reason),
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
},
|
||||
usage,
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type ModelToolSchemaCompatibility,
|
||||
@@ -435,9 +436,10 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
// =============================================================================
|
||||
const mapFinishReason = (reason: string): FinishReason => {
|
||||
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
|
||||
if (reason === "max_tokens") return "length"
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
||||
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -466,7 +468,7 @@ interface ParserState {
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
// can emit exactly one finish after both chunks have had a chance to arrive.
|
||||
readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
|
||||
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
|
||||
readonly hasToolCalls: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
@@ -583,7 +585,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage },
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.messageStop.stopReason),
|
||||
raw: event.messageStop.stopReason,
|
||||
},
|
||||
usage: state.pendingFinish?.usage,
|
||||
},
|
||||
},
|
||||
[],
|
||||
] as const
|
||||
@@ -591,7 +599,16 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
|
||||
if (event.metadata) {
|
||||
const usage = mapUsage(event.metadata.usage)
|
||||
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
pendingFinish: {
|
||||
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
|
||||
usage,
|
||||
},
|
||||
},
|
||||
[],
|
||||
] as const
|
||||
}
|
||||
|
||||
const exception = (
|
||||
@@ -624,8 +641,13 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason:
|
||||
state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
|
||||
reason: {
|
||||
...state.pendingFinish.reason,
|
||||
normalized:
|
||||
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
|
||||
? "tool-calls"
|
||||
: state.pendingFinish.reason.normalized,
|
||||
},
|
||||
usage: state.pendingFinish.usage,
|
||||
})
|
||||
return events
|
||||
|
||||
@@ -382,10 +382,22 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
finishReason === "SAFETY" ||
|
||||
finishReason === "BLOCKLIST" ||
|
||||
finishReason === "PROHIBITED_CONTENT" ||
|
||||
finishReason === "SPII"
|
||||
finishReason === "SPII" ||
|
||||
finishReason === "MODEL_ARMOR" ||
|
||||
finishReason === "IMAGE_PROHIBITED_CONTENT" ||
|
||||
finishReason === "IMAGE_RECITATION" ||
|
||||
finishReason === "LANGUAGE"
|
||||
)
|
||||
return "content-filter"
|
||||
if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
|
||||
if (
|
||||
finishReason === "MALFORMED_FUNCTION_CALL" ||
|
||||
finishReason === "UNEXPECTED_TOOL_CALL" ||
|
||||
finishReason === "NO_IMAGE" ||
|
||||
finishReason === "TOO_MANY_TOOL_CALLS" ||
|
||||
finishReason === "MISSING_THOUGHT_SIGNATURE" ||
|
||||
finishReason === "MALFORMED_RESPONSE"
|
||||
)
|
||||
return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -402,7 +414,10 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
reason: {
|
||||
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
raw: state.finishReason,
|
||||
},
|
||||
usage: state.usage,
|
||||
})
|
||||
return events
|
||||
|
||||
@@ -5,9 +5,11 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { classifyProviderFailure } from "../provider-error"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
@@ -164,11 +167,18 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
const OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
message: Schema.String,
|
||||
})
|
||||
|
||||
export const OpenAIChatEvent = Schema.Struct({
|
||||
choices: Schema.Array(OpenAIChatChoice),
|
||||
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
error: optionalNull(OpenAIChatError),
|
||||
})
|
||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||
@@ -184,7 +194,7 @@ export interface ParserState {
|
||||
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReason
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningField?: string
|
||||
readonly reasoningDetails: Array<unknown>
|
||||
@@ -439,6 +449,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
if (reason === "length") return "length"
|
||||
if (reason === "content_filter") return "content-filter"
|
||||
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
|
||||
if (reason === "error") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -532,10 +543,22 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
|
||||
|
||||
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.error)
|
||||
return yield* new LLMError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
const choice = event.choices[0]
|
||||
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
|
||||
const choice = event.choices?.[0]
|
||||
const finishReason = choice?.finish_reason
|
||||
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
|
||||
: state.finishReason
|
||||
const delta = choice?.delta
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
@@ -627,7 +650,13 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const events: LLMEvent[] = []
|
||||
const hasToolCalls = state.toolCallEvents.length > 0
|
||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||
const reason = state.finishReason
|
||||
? {
|
||||
...state.finishReason,
|
||||
normalized:
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: undefined
|
||||
const metadata = reasoningMetadata(
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
|
||||
@@ -979,7 +979,10 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||
reason: {
|
||||
normalized: mapFinishReason(event, state.hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
},
|
||||
usage: mapUsage(event.response?.usage),
|
||||
providerMetadata:
|
||||
event.response?.id || event.response?.service_tier
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
|
||||
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
|
||||
|
||||
export interface State {
|
||||
readonly stepStarted: boolean
|
||||
@@ -81,7 +81,7 @@ export const finish = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
input: {
|
||||
readonly reason: FinishReason
|
||||
readonly reason: FinishReasonDetails
|
||||
readonly usage?: Usage
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
},
|
||||
|
||||
@@ -191,10 +191,16 @@ export const ToolError = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.ToolError" })
|
||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||
|
||||
export const FinishReasonDetails = Schema.Struct({
|
||||
normalized: FinishReason,
|
||||
raw: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "LLM.FinishReasonDetails" })
|
||||
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
|
||||
|
||||
export const StepFinish = Schema.Struct({
|
||||
type: Schema.tag("step-finish"),
|
||||
index: Schema.Number,
|
||||
reason: FinishReason,
|
||||
reason: FinishReasonDetails,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.StepFinish" })
|
||||
@@ -202,7 +208,7 @@ export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
||||
|
||||
export const Finish = Schema.Struct({
|
||||
type: Schema.tag("finish"),
|
||||
reason: FinishReason,
|
||||
reason: FinishReasonDetails,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||
@@ -365,7 +371,7 @@ interface ResponseState {
|
||||
readonly events: ReadonlyArray<LLMEvent>
|
||||
readonly message: Message
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReason
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
readonly textParts: Readonly<Record<string, ContentAssembly>>
|
||||
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
|
||||
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
|
||||
@@ -393,7 +399,7 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
return {
|
||||
...state,
|
||||
events,
|
||||
finishReason: state.finishReason ?? "error",
|
||||
finishReason: state.finishReason ?? { normalized: "error" },
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -580,7 +586,7 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
|
||||
message: Message,
|
||||
events: Schema.Array(LLMEvent),
|
||||
usage: Schema.optional(Usage),
|
||||
finishReason: FinishReason,
|
||||
finishReason: FinishReasonDetails,
|
||||
}) {
|
||||
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
||||
get text() {
|
||||
|
||||
@@ -40,7 +40,7 @@ const fakeFraming: FramingDef<FakeEvent> = {
|
||||
|
||||
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||
event.type === "finish"
|
||||
? { type: "finish", reason: event.reason }
|
||||
? { type: "finish", reason: { normalized: event.reason } }
|
||||
: { type: "text-delta", id: "text-0", text: event.text }
|
||||
|
||||
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||
|
||||
@@ -83,7 +83,7 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
|
||||
const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const assistantContent: ContentPart[] = []
|
||||
const toolCalls: ToolCallPart[] = []
|
||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
|
||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = { normalized: "unknown" }
|
||||
let usage: Usage | undefined
|
||||
let providerMetadata: ProviderMetadata | undefined
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("llm constructors", () => {
|
||||
LLMResponse.text({
|
||||
events: [
|
||||
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
{ type: "finish", reason: { normalized: "stop" } },
|
||||
],
|
||||
}),
|
||||
).toBe("hi")
|
||||
|
||||
@@ -448,12 +448,50 @@ describe("Anthropic Messages route", () => {
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps context-window truncation to length", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "model_context_window_exceeded" },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "length", raw: "model_context_window_exceeded" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves pause_turn while normalizing it to stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "pause_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -503,10 +541,16 @@ describe("Anthropic Messages route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "tool-calls",
|
||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
||||
providerMetadata: undefined,
|
||||
usage,
|
||||
},
|
||||
@@ -674,7 +718,10 @@ describe("Anthropic Messages route", () => {
|
||||
},
|
||||
})
|
||||
expect(response.text).toBe("Found it.")
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -290,7 +290,10 @@ describe("Bedrock Converse route", () => {
|
||||
// `metadata` (carries usage). We consolidate them into a single
|
||||
// terminal `finish` event with both.
|
||||
expect(finishes).toHaveLength(1)
|
||||
expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(finishes[0]).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
})
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
@@ -299,6 +302,23 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps truncation and malformed output stop reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["model_context_window_exceeded", "length"],
|
||||
["malformed_model_output", "error"],
|
||||
["malformed_tool_use", "error"],
|
||||
] as const
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds cache reads and writes to Bedrock input usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
@@ -362,7 +382,10 @@ describe("Bedrock Converse route", () => {
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -388,7 +411,7 @@ describe("Bedrock Converse route", () => {
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -373,10 +373,16 @@ describe("Gemini route", () => {
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "stop", raw: "STOP" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: "STOP" },
|
||||
usage,
|
||||
},
|
||||
])
|
||||
@@ -529,10 +535,16 @@ describe("Gemini route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "tool-calls",
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
usage,
|
||||
},
|
||||
])
|
||||
@@ -571,7 +583,10 @@ describe("Gemini route", () => {
|
||||
},
|
||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -591,9 +606,41 @@ describe("Gemini route", () => {
|
||||
)
|
||||
|
||||
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||
expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
|
||||
expect(length.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "length", raw: "MAX_TOKENS" },
|
||||
})
|
||||
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||
expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
|
||||
expect(filtered.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "content-filter", raw: "SAFETY" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["MODEL_ARMOR", "content-filter"],
|
||||
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
|
||||
["IMAGE_RECITATION", "content-filter"],
|
||||
["LANGUAGE", "content-filter"],
|
||||
["UNEXPECTED_TOOL_CALL", "error"],
|
||||
["NO_IMAGE", "error"],
|
||||
["IMAGE_OTHER", "unknown"],
|
||||
["TOO_MANY_TOOL_CALLS", "error"],
|
||||
["MISSING_THOUGHT_SIGNATURE", "error"],
|
||||
["MALFORMED_RESPONSE", "error"],
|
||||
] as const
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: raw }] })),
|
||||
),
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -569,10 +569,16 @@ describe("OpenAI Chat route", () => {
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "stop", raw: "stop" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: "stop" },
|
||||
usage,
|
||||
},
|
||||
])
|
||||
@@ -1037,8 +1043,14 @@ describe("OpenAI Chat route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
|
||||
{ type: "finish", reason: "tool-calls", usage: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: "tool_calls" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "tool-calls", raw: "tool_calls" }, usage: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -232,7 +232,10 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "stop" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -856,13 +856,13 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: undefined },
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop", raw: undefined },
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage,
|
||||
},
|
||||
@@ -887,11 +887,18 @@ describe("OpenAI Responses route", () => {
|
||||
const length = yield* generate({ reason: "max_output_tokens" })
|
||||
const contentFilter = yield* generate({ reason: "content_filter" })
|
||||
const unknown = yield* generate({})
|
||||
const custom = yield* generate({ reason: "provider_limit" })
|
||||
|
||||
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
|
||||
"length",
|
||||
"content-filter",
|
||||
"unknown",
|
||||
expect([
|
||||
length.finishReason,
|
||||
contentFilter.finishReason,
|
||||
unknown.finishReason,
|
||||
custom.finishReason,
|
||||
]).toEqual([
|
||||
{ normalized: "length", raw: "max_output_tokens" },
|
||||
{ normalized: "content-filter", raw: "content_filter" },
|
||||
{ normalized: "unknown", raw: undefined },
|
||||
{ normalized: "unknown", raw: "provider_limit" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -946,8 +953,8 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "reasoning-end", id: "rs_1" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.message.content).toEqual([
|
||||
@@ -1038,8 +1045,8 @@ describe("OpenAI Responses route", () => {
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1422,10 +1429,16 @@ describe("OpenAI Responses route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "tool-calls",
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
providerMetadata: undefined,
|
||||
usage,
|
||||
},
|
||||
@@ -1465,7 +1478,7 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
@@ -1492,7 +1505,7 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { LLM, Message } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { it } from "../lib/effect"
|
||||
import { fixedResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
|
||||
describe("OpenRouter", () => {
|
||||
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
|
||||
@@ -54,6 +56,42 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the upstream provider finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails on a mid-stream provider error", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
error: { code: 502, message: "Provider disconnected" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.message).toContain("Provider disconnected")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves manually supplied reasoning details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
|
||||
@@ -106,7 +106,7 @@ const readPdfRuntime = Tool.make({
|
||||
})
|
||||
|
||||
const expectCode = (response: LLMResponse) => {
|
||||
expect(response.finishReason).toBe("stop")
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
expect(response.text.toUpperCase()).toContain(CODE)
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("PDF recorded", () => {
|
||||
tools: { read_pdf: readPdfRuntime },
|
||||
}).pipe(Stream.runCollect),
|
||||
)
|
||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "stop" } })
|
||||
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@ const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
|
||||
|
||||
export const expectFinish = (
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
|
||||
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
|
||||
reason: FinishReason,
|
||||
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
|
||||
|
||||
export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
@@ -136,10 +136,10 @@ export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const finishes = events.filter(LLMEvent.is.finish)
|
||||
expect(finishes).toHaveLength(1)
|
||||
expect(finishes[0]?.reason).toBe("stop")
|
||||
expect(finishes[0]?.reason.normalized).toBe("stop")
|
||||
|
||||
const stepFinishes = events.filter(LLMEvent.is.stepFinish)
|
||||
expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
|
||||
expect(stepFinishes.map((event) => event.reason.normalized)).toEqual(["tool-calls", "stop"])
|
||||
|
||||
const toolCalls = events.filter(LLMEvent.is.toolCall)
|
||||
expect(toolCalls).toHaveLength(1)
|
||||
@@ -503,7 +503,7 @@ export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
|
||||
continue
|
||||
}
|
||||
if (event.type === "finish") {
|
||||
summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
|
||||
summary.push({ type: "finish", reason: event.reason.normalized, usage: usageSummary(event.usage) })
|
||||
}
|
||||
}
|
||||
return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
|
||||
|
||||
@@ -14,11 +14,11 @@ describe("LLMResponse reducer", () => {
|
||||
LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }),
|
||||
LLMEvent.textDelta({ id: "t1", text: "Answer" }),
|
||||
LLMEvent.textEnd({ id: "t1" }),
|
||||
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 5 } }),
|
||||
]
|
||||
const response = LLMResponse.fromEvents(events)
|
||||
|
||||
expect(response?.finishReason).toBe("stop")
|
||||
expect(response?.finishReason).toEqual({ normalized: "stop" })
|
||||
expect(response?.usage).toMatchObject({ outputTokens: 5 })
|
||||
expect(response?.events).toEqual(events)
|
||||
expect(response?.events.map((event) => event.type)).toEqual([
|
||||
@@ -62,18 +62,26 @@ describe("LLMResponse reducer", () => {
|
||||
|
||||
test("uses terminal usage when present and keeps prior usage when finish omits it", () => {
|
||||
const withFinishUsage = LLMResponse.fromEvents([
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }),
|
||||
])
|
||||
const withoutFinishUsage = LLMResponse.fromEvents([
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 })
|
||||
expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 })
|
||||
})
|
||||
|
||||
test("preserves the raw finish reason", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.finish({ reason: { normalized: "unknown", raw: "provider_limit" } }),
|
||||
])
|
||||
|
||||
expect(response?.finishReason).toEqual({ normalized: "unknown", raw: "provider_limit" })
|
||||
})
|
||||
|
||||
test("assembles tool-call content only after the completed tool call event", () => {
|
||||
const pending = reduce([
|
||||
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||
@@ -88,7 +96,7 @@ describe("LLMResponse reducer", () => {
|
||||
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }),
|
||||
LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([
|
||||
|
||||
@@ -48,8 +48,12 @@ describe("llm schema", () => {
|
||||
})
|
||||
|
||||
test("finish constructors accept usage input", () => {
|
||||
expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage)
|
||||
expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
|
||||
expect(
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage,
|
||||
).toBeInstanceOf(Usage)
|
||||
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(
|
||||
Usage,
|
||||
)
|
||||
})
|
||||
|
||||
test("content part tagged union exposes guards", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./values.js"
|
||||
|
||||
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
|
||||
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
|
||||
|
||||
export type Services<T> = ServicesOf<T, []>
|
||||
|
||||
@@ -326,12 +327,15 @@ const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
|
||||
})
|
||||
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
const visibleTools = <R>(tools: Tools<R>) =>
|
||||
flattenTools(toolTrie(tools)).map(({ path, tool }) => ({
|
||||
path,
|
||||
tool,
|
||||
description: describeTool(path, tool),
|
||||
}))
|
||||
flattenTools(toolTrie(tools))
|
||||
.sort((left, right) => compareText(left.path, right.path))
|
||||
.map(({ path, tool }) => ({
|
||||
path,
|
||||
tool,
|
||||
description: describeTool(path, tool),
|
||||
}))
|
||||
|
||||
export type DiscoveryPlan = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
@@ -403,7 +407,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
.filter(({ score }) => terms.length === 0 || score > 0)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path),
|
||||
right.score - left.score || compareText(left.entry.description.path, right.entry.description.path),
|
||||
)
|
||||
.map(({ entry }) => entry)
|
||||
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
|
||||
@@ -462,14 +466,14 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
|
||||
group.push(tool)
|
||||
namespaces.set(namespace, group)
|
||||
}
|
||||
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
|
||||
const ordered = [...namespaces].sort(([left], [right]) => compareText(left, right))
|
||||
|
||||
const selections = ordered.map(([namespace, group]) => ({
|
||||
namespace,
|
||||
picked: new Set<ToolDescription>(),
|
||||
queue: [...group].sort(
|
||||
(left, right) =>
|
||||
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path),
|
||||
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || compareText(left.path, right.path),
|
||||
),
|
||||
}))
|
||||
let used = 0
|
||||
|
||||
@@ -629,6 +629,41 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("renders equivalent catalogs identically regardless of tool insertion order", () => {
|
||||
const alpha = Tool.make({
|
||||
description: "Alpha tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Void,
|
||||
execute: () => Effect.void,
|
||||
})
|
||||
const zeta = Tool.make({
|
||||
description: "Zeta tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Void,
|
||||
execute: () => Effect.void,
|
||||
})
|
||||
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
|
||||
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
|
||||
|
||||
expect(first.catalog()).toStrictEqual(second.catalog())
|
||||
expect(first.instructions()).toBe(second.instructions())
|
||||
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
|
||||
for (const catalogBudget of [0, 10, 20, 40]) {
|
||||
expect(
|
||||
CodeMode.make({
|
||||
tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } },
|
||||
discovery: { catalogBudget },
|
||||
}).instructions(),
|
||||
).toBe(
|
||||
CodeMode.make({
|
||||
tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } },
|
||||
discovery: { catalogBudget },
|
||||
}).instructions(),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
|
||||
const resolveLibrary = Tool.make({
|
||||
description: "Resolve a library ID",
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("blocked member names on tool paths", () => {
|
||||
})
|
||||
|
||||
test("tools may use blocked member names because path segments never touch real properties", async () => {
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["prototype", "issues.constructor", "nested.__proto__"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
|
||||
expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
|
||||
expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
|
||||
expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
|
||||
@@ -155,8 +155,7 @@ describe("canonical path collisions", () => {
|
||||
"issues.close": echo("Close issue", "closed"),
|
||||
},
|
||||
})
|
||||
// Catalog order follows first appearance of each canonical path.
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.list", "issues.get", "issues.close"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
|
||||
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
|
||||
expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
|
||||
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
|
||||
|
||||
@@ -659,12 +659,12 @@ function streamPartEvents(
|
||||
return Effect.succeed([
|
||||
LLMEvent.stepFinish({
|
||||
index: state.step++,
|
||||
reason: finishReason(event.finishReason),
|
||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||
usage: usage(event.usage),
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: finishReason(event.finishReason),
|
||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||
usage: usage(event.usage),
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
|
||||
@@ -1,45 +1,24 @@
|
||||
export * as CodeModeInstructions from "./instructions"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { CodeMode } from "../codemode"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Instructions } from "../instructions/index"
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
|
||||
const key = Instructions.Key.make("core/codemode")
|
||||
const codec = Schema.toCodecJson(Schema.String)
|
||||
const render = {
|
||||
initial: (current: string) => current,
|
||||
changed: (_previous: string, current: string) =>
|
||||
[
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
|
||||
current,
|
||||
].join("\n\n"),
|
||||
removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeModeInstructions") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const codeMode = yield* CodeMode.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("CodeModeInstructions.load")(function* (selection) {
|
||||
const instructions = selection.info
|
||||
? (yield* codeMode.materialize(selection.info.permissions)).instructions
|
||||
: undefined
|
||||
return Instructions.make({
|
||||
key: Instructions.Key.make("core/codemode"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.succeed(instructions ?? Instructions.removed),
|
||||
render: {
|
||||
initial: (current) => current,
|
||||
changed: (_previous, current) =>
|
||||
[
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
|
||||
current,
|
||||
].join("\n\n"),
|
||||
removed: () =>
|
||||
"Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [CodeMode.node] })
|
||||
export const make = (content?: string): Instructions.Instructions =>
|
||||
Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
read: Effect.succeed(content ?? Instructions.removed),
|
||||
render,
|
||||
})
|
||||
|
||||
@@ -44,11 +44,6 @@ export interface WriteResult {
|
||||
readonly existed: boolean
|
||||
}
|
||||
|
||||
export interface TextWriteResult extends WriteResult {
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
}
|
||||
|
||||
export interface RemoveResult {
|
||||
readonly operation: "remove"
|
||||
readonly target: string
|
||||
@@ -61,7 +56,7 @@ export interface Interface {
|
||||
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<TextWriteResult, FSUtil.Error>
|
||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
/** Commit only if an existing target still has the expected bytes. */
|
||||
readonly writeIfUnchanged: (
|
||||
input: ConditionalWriteInput,
|
||||
@@ -117,13 +112,11 @@ const layer = Layer.effect(
|
||||
const current = yield* fs
|
||||
.readFile(input.target.canonical)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
const content = joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom)
|
||||
yield* fs.writeWithDirs(input.target.canonical, content)
|
||||
return {
|
||||
...writeResult(input.target, current !== undefined),
|
||||
before: current ? new TextDecoder().decode(current).replace(/^\uFEFF/, "") : "",
|
||||
after: content.replace(/^\uFEFF/, ""),
|
||||
}
|
||||
yield* fs.writeWithDirs(
|
||||
input.target.canonical,
|
||||
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
|
||||
)
|
||||
return writeResult(input.target, current !== undefined)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CodeMode } from "./codemode"
|
||||
import { CodeModeInstructions } from "./codemode/instructions"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Config } from "./config"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -82,7 +81,6 @@ const locationServiceNodes = [
|
||||
ToolRegistry.toolsNode,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
CodeModeInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SessionContext from "./context"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { CodeModeInstructions } from "../codemode/instructions"
|
||||
import { Database } from "../database/database"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { InstructionDiscovery } from "../instruction-discovery"
|
||||
@@ -12,7 +13,7 @@ import { McpInstructions } from "../mcp/instructions"
|
||||
import { PluginSupervisor } from "../plugin/supervisor"
|
||||
import { ReferenceInstructions } from "../reference/instructions"
|
||||
import { SkillInstructions } from "../skill/instructions"
|
||||
import { CodeModeInstructions } from "../codemode/instructions"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { AgentNotFoundError } from "./error"
|
||||
import { SessionHistory } from "./history"
|
||||
import { InstructionEntry } from "./instruction-entry"
|
||||
@@ -25,6 +26,7 @@ export interface Selection {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agent: AgentV2.Selection & { readonly info: AgentV2.Info }
|
||||
readonly instructions: Instructions.Instructions
|
||||
readonly toolSet: ToolRegistry.ToolSet
|
||||
}
|
||||
|
||||
export interface Loaded {
|
||||
@@ -33,15 +35,17 @@ export interface Loaded {
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly initial: string
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
readonly toolSet: ToolRegistry.ToolSet
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves model-request state in two phases: `select` fixes the Session,
|
||||
* agent, and instruction sources; `load` adds the model and active history for
|
||||
* that selection. This module does not build or execute the model request.
|
||||
* agent, instruction sources, and tool snapshot; `load` adds the model and
|
||||
* active history for that selection. This module does not build or execute the
|
||||
* model request.
|
||||
*/
|
||||
export interface Interface {
|
||||
/** Selects the Session, agent, and instruction sources used by subsequent work. */
|
||||
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
|
||||
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
|
||||
/** Resolves the model and active history for that selection. */
|
||||
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
|
||||
@@ -55,7 +59,6 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const builtins = yield* InstructionBuiltIns.Service
|
||||
const codeModeInstructions = yield* CodeModeInstructions.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
const entries = yield* InstructionEntry.Service
|
||||
@@ -66,6 +69,7 @@ const layer = Layer.effect(
|
||||
const referenceInstructions = yield* ReferenceInstructions.Service
|
||||
const skillInstructions = yield* SkillInstructions.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
@@ -76,19 +80,32 @@ const layer = Layer.effect(
|
||||
yield* plugins.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
const instructions = yield* Effect.all(
|
||||
[
|
||||
builtins.load(sessionID),
|
||||
codeModeInstructions.load(agent),
|
||||
discovery.load(),
|
||||
skillInstructions.load(agent),
|
||||
referenceInstructions.load(),
|
||||
mcpInstructions.load(agent),
|
||||
entries.load(sessionID),
|
||||
],
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
toolSet: registry.snapshot(agent.info.permissions),
|
||||
builtins: builtins.load(sessionID),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(agent),
|
||||
references: referenceInstructions.load(),
|
||||
mcp: mcpInstructions.load(agent),
|
||||
entries: entries.load(sessionID),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map(Instructions.combine))
|
||||
return { session, agent: { ...agent, info: agent.info }, instructions }
|
||||
)
|
||||
return {
|
||||
session,
|
||||
agent: { ...agent, info: agent.info },
|
||||
instructions: Instructions.combine([
|
||||
loaded.builtins,
|
||||
CodeModeInstructions.make(loaded.toolSet.codeModeInstructions),
|
||||
loaded.discovery,
|
||||
loaded.skills,
|
||||
loaded.references,
|
||||
loaded.mcp,
|
||||
loaded.entries,
|
||||
]),
|
||||
toolSet: loaded.toolSet,
|
||||
}
|
||||
})
|
||||
|
||||
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
|
||||
@@ -100,6 +117,7 @@ const layer = Layer.effect(
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.entries.map((entry) => entry.message),
|
||||
toolSet: selection.toolSet,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -112,7 +130,6 @@ export const node = makeLocationNode({
|
||||
layer,
|
||||
deps: [
|
||||
AgentV2.node,
|
||||
CodeModeInstructions.node,
|
||||
Database.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
@@ -124,5 +141,6 @@ export const node = makeLocationNode({
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
SkillInstructions.node,
|
||||
ToolRegistry.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import { SessionGenerate } from "./generate"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
|
||||
@@ -24,7 +23,6 @@ export const layer = Layer.effect(
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const app = yield* App.Metadata
|
||||
|
||||
return SessionGenerate.Service.of({
|
||||
@@ -36,7 +34,7 @@ export const layer = Layer.effect(
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
|
||||
? selection.session.id.slice(4)
|
||||
: selection.session.id
|
||||
const toolSet = yield* registry.snapshot(selection.agent.info.permissions)
|
||||
const toolSet = selection.toolSet
|
||||
const toolDefinitions = toolSet.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
@@ -89,13 +87,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: SessionGenerate.Service,
|
||||
layer,
|
||||
deps: [
|
||||
SessionContext.node,
|
||||
Database.node,
|
||||
PluginHooks.node,
|
||||
SessionRunnerModel.node,
|
||||
ToolRegistry.node,
|
||||
App.node,
|
||||
llmClient,
|
||||
],
|
||||
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
|
||||
})
|
||||
|
||||
@@ -86,7 +86,6 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const app = yield* App.Metadata
|
||||
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
||||
@@ -98,7 +97,7 @@ export const layer = Layer.effect(
|
||||
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
const toolSet = yield* registry.snapshot(agent.info.permissions)
|
||||
const toolSet = input.context.toolSet
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
|
||||
.filter((part) => part.length > 0)
|
||||
@@ -162,5 +161,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [PluginHooks.node, ToolRegistry.node, App.node],
|
||||
deps: [PluginHooks.node, App.node],
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement:
|
||||
| {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
| undefined
|
||||
@@ -449,8 +449,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
|
||||
if (event.reason === "content-filter") {
|
||||
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
|
||||
if (event.reason.normalized === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
@@ -81,12 +82,11 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string, error?: unknown) => {
|
||||
const prefix =
|
||||
applied.length === 0
|
||||
? `Unable to apply patch at ${path}`
|
||||
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
||||
return new ToolFailure({ message: prefix, error })
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
@@ -101,11 +101,7 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
const normalized = input.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
return yield* new ToolFailure({ message: "patch verification failed: no hunks found" })
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
@@ -145,7 +141,7 @@ export const Plugin = {
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -161,7 +157,7 @@ export const Plugin = {
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -175,7 +171,7 @@ export const Plugin = {
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -184,7 +180,8 @@ export const Plugin = {
|
||||
const before = original.replace(/^\uFEFF/, "")
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
|
||||
catch: (error) =>
|
||||
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
})
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
@@ -211,7 +208,13 @@ export const Plugin = {
|
||||
moveTarget,
|
||||
})
|
||||
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail(hunk.path, error))))
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map(patchFile)
|
||||
@@ -234,12 +237,16 @@ export const Plugin = {
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* fs.writeWithDirs(
|
||||
change.target.canonical,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.canonical,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
@@ -248,7 +255,11 @@ export const Plugin = {
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* fs.remove(change.target.canonical)
|
||||
yield* fs
|
||||
.remove(change.target.canonical)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
@@ -257,8 +268,15 @@ export const Plugin = {
|
||||
return
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
yield* fs.writeWithDirs(change.moveTarget.canonical, change.content)
|
||||
yield* fs.remove(change.target.canonical)
|
||||
const moveTarget = change.moveTarget
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* fs.remove(change.target.canonical).pipe(
|
||||
Effect.mapError((error) =>
|
||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||
),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.moveTarget.resource,
|
||||
@@ -266,13 +284,15 @@ export const Plugin = {
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* fs.writeWithDirs(change.target.canonical, change.content)
|
||||
yield* fs
|
||||
.writeWithDirs(change.target.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.canonical,
|
||||
})
|
||||
}).pipe(Effect.mapError((error) => fail(change.path, error))),
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied, files: patchFiles }
|
||||
@@ -282,7 +302,11 @@ export const Plugin = {
|
||||
content: toModelOutput(output),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: "Unable to apply patch", error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
@@ -306,6 +330,14 @@ export const Plugin = {
|
||||
}),
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof PlatformError) {
|
||||
if (error.reason._tag === "NotFound") return "file does not exist"
|
||||
return error.reason.description ?? error.reason.message
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const patch = trimDiff(
|
||||
|
||||
@@ -44,12 +44,13 @@ export interface Interface {
|
||||
}
|
||||
|
||||
/**
|
||||
* One request-scoped snapshot pairing advertised definitions with captured
|
||||
* tools. A model request executes exactly the tool values it advertised
|
||||
* even if registration changes while the request is in flight.
|
||||
* One request-scoped snapshot pairing Code Mode instructions and advertised
|
||||
* definitions with captured tools. A model request executes exactly the tool
|
||||
* values it advertised even if registration changes while it is in flight.
|
||||
*/
|
||||
export interface ToolSet {
|
||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||
readonly codeModeInstructions?: string
|
||||
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
|
||||
}
|
||||
|
||||
@@ -320,10 +321,17 @@ const registryLayer = Layer.effect(
|
||||
if (whollyDisabled(registration.permission, rules)) continue
|
||||
direct.set(name, registration)
|
||||
}
|
||||
const codemodeTool = (yield* codeMode.materialize(permissions)).tool
|
||||
const codeModeMaterialization = yield* codeMode.materialize(permissions)
|
||||
const codemodeTool = codeModeMaterialization.tool
|
||||
return {
|
||||
...(codeModeMaterialization.instructions === undefined
|
||||
? {}
|
||||
: { codeModeInstructions: codeModeMaterialization.instructions }),
|
||||
definitions: [
|
||||
...Array.from(direct, ([name, registration]) => toLLMDefinition(name, registration.tool)),
|
||||
// Definitions are prompt-cache prefix bytes, so order only after effective registrations settle.
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([name, registration]) => toLLMDefinition(name, registration.tool)),
|
||||
...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []),
|
||||
],
|
||||
execute: (input: ExecuteInput) => {
|
||||
|
||||
@@ -8,8 +8,6 @@ export * as WriteTool from "./write"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
@@ -32,7 +30,6 @@ export const Output = Schema.Struct({
|
||||
target: Schema.String,
|
||||
resource: Schema.String,
|
||||
existed: Schema.Boolean,
|
||||
files: Schema.Array(FileDiff.Info),
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
||||
@@ -85,28 +82,7 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const counts = diffLines(result.before, result.after).reduce(
|
||||
(total, item) => ({
|
||||
additions: total.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: total.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
return {
|
||||
operation: result.operation,
|
||||
target: result.target,
|
||||
resource: result.resource,
|
||||
existed: result.existed,
|
||||
files: [
|
||||
{
|
||||
file: result.resource,
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, result.before, result.after),
|
||||
status: result.existed ? "modified" : "added",
|
||||
...counts,
|
||||
},
|
||||
],
|
||||
} satisfies Output
|
||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
|
||||
@@ -299,6 +299,7 @@ it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
})
|
||||
expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,49 +1,66 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { CodeMode } from "@opencode-ai/core/codemode"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { readInitial, readUpdate } from "../lib/instructions"
|
||||
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(AgentV2.ID.make("build")))
|
||||
|
||||
describe("CodeModeInstructions", () => {
|
||||
it.effect("renders catalog changes and removal", () => {
|
||||
let catalog: string | undefined = "Initial Code Mode catalog"
|
||||
const layer = AppNodeBuilder.build(CodeModeInstructions.node, [
|
||||
[
|
||||
CodeMode.node,
|
||||
Layer.mock(CodeMode.Service, {
|
||||
materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { instructions: catalog }) }),
|
||||
register: () => Effect.void,
|
||||
}),
|
||||
],
|
||||
])
|
||||
it.effect("treats equivalent registration orders as an instruction no-op", () => {
|
||||
const alpha = Tool.make({
|
||||
description: "Alpha tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "alpha" }),
|
||||
})
|
||||
const zeta = Tool.make({
|
||||
description: "Zeta tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "zeta" }),
|
||||
})
|
||||
const codeModeLayer = AppNodeBuilder.build(CodeMode.node)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* CodeModeInstructions.Service
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
const codeMode = yield* CodeMode.Service
|
||||
const initialized = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* codeMode.register(Tool.registrationEntries({ zeta, alpha }, { namespace: "tools" }))
|
||||
const materialization = yield* codeMode.materialize()
|
||||
return yield* readInitial(CodeModeInstructions.make(materialization.instructions))
|
||||
}),
|
||||
)
|
||||
const reordered = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* codeMode.register(Tool.registrationEntries({ alpha, zeta }, { namespace: "tools" }))
|
||||
const materialization = yield* codeMode.materialize()
|
||||
return yield* readUpdate(CodeModeInstructions.make(materialization.instructions), initialized)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(reordered.changed).toBe(false)
|
||||
expect(reordered.text).toBe("")
|
||||
}).pipe(Effect.provide(codeModeLayer))
|
||||
})
|
||||
|
||||
it.effect("renders catalog changes and removal", () => {
|
||||
let catalog: string | undefined = "Initial Code Mode catalog"
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make(catalog))
|
||||
expect(initialized.text).toBe("Initial Code Mode catalog")
|
||||
|
||||
catalog = "Updated Code Mode catalog"
|
||||
expect(
|
||||
yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({
|
||||
expect(yield* readUpdate(CodeModeInstructions.make(catalog), initialized)).toMatchObject({
|
||||
text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\nUpdated Code Mode catalog",
|
||||
})
|
||||
|
||||
catalog = undefined
|
||||
expect(
|
||||
yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({
|
||||
expect(yield* readUpdate(CodeModeInstructions.make(catalog), initialized)).toMatchObject({
|
||||
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,14 +80,9 @@ describe("FileMutation", () => {
|
||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
const preservedResult = yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
||||
const createdResult = yield* files.writeTextPreservingBom({
|
||||
target: created,
|
||||
content: "\uFEFF\uFEFF\uFEFFcreated",
|
||||
})
|
||||
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
||||
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||
|
||||
expect(preservedResult).toMatchObject({ existed: true, before: "before", after: "after" })
|
||||
expect(createdResult).toMatchObject({ existed: false, before: "", after: "created" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||
}).pipe(provide(directory)),
|
||||
|
||||
@@ -73,7 +73,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
LLMEvent.textStart({ id: "generate" }),
|
||||
LLMEvent.textDelta({ id: "generate", text: "OK" }),
|
||||
LLMEvent.textEnd({ id: "generate" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
if (!response) throw new Error("Incomplete generate response")
|
||||
return response
|
||||
|
||||
@@ -578,7 +578,8 @@ describe("LocationServiceMap", () => {
|
||||
const blockedState = yield* update(blocked.path, blockedID)
|
||||
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
|
||||
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
|
||||
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
const blockedTools = blockedState.tools.map((tool) => tool.name)
|
||||
expect(blockedTools.filter((name) => name !== "execute").sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
@@ -595,7 +596,9 @@ describe("LocationServiceMap", () => {
|
||||
const allowedState = yield* update(allowed.path, allowedID)
|
||||
expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
|
||||
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
|
||||
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
const allowedTools = allowedState.tools.map((tool) => tool.name)
|
||||
expect(blockedTools.includes("execute")).toBe(allowedTools.includes("execute"))
|
||||
expect(allowedTools.filter((name) => name !== "execute").sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
|
||||
@@ -246,6 +246,35 @@ describe("Patch", () => {
|
||||
).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
|
||||
})
|
||||
|
||||
test("appends a pure-addition chunk to a nonempty file", () => {
|
||||
expect(Patch.derive("update.txt", [{ oldLines: [], newLines: ["added 1", "added 2"] }], "line 1\nline 2\n").content).toBe(
|
||||
"line 1\nline 2\nadded 1\nadded 2\n",
|
||||
)
|
||||
})
|
||||
|
||||
test("applies a pure-addition chunk after an earlier replacement", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"update.txt",
|
||||
[
|
||||
{ oldLines: [], newLines: ["after-context", "second-line"] },
|
||||
{ oldLines: ["line1", "line2", "line3"], newLines: ["line1", "line2-replacement"] },
|
||||
],
|
||||
"line1\nline2\nline3\n",
|
||||
).content,
|
||||
).toBe("line1\nline2-replacement\nafter-context\nsecond-line\n")
|
||||
})
|
||||
|
||||
test("applies a deletion-only update chunk", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"update.txt",
|
||||
[{ oldLines: ["line1", "line2", "line3"], newLines: ["line1", "line3"] }],
|
||||
"line1\nline2\nline3\n",
|
||||
).content,
|
||||
).toBe("line1\nline3\n")
|
||||
})
|
||||
|
||||
test("updates empty files and adds a trailing newline", () => {
|
||||
expect(Patch.derive("empty.txt", [{ oldLines: [], newLines: ["First line"] }], "").content).toBe("First line\n")
|
||||
expect(Patch.derive("no-newline.txt", [{ oldLines: ["old"], newLines: ["new"] }], "old").content).toBe("new\n")
|
||||
@@ -327,6 +356,12 @@ describe("Patch", () => {
|
||||
).toThrow("Failed to find expected lines")
|
||||
})
|
||||
|
||||
test("identifies a missing blank line", () => {
|
||||
expect(() =>
|
||||
Patch.derive("update.txt", [{ oldLines: [""], newLines: ["added"] }], "content\n"),
|
||||
).toThrow("Failed to find an expected blank line in update.txt")
|
||||
})
|
||||
|
||||
test("parses an update without an explicit first chunk header", () => {
|
||||
expect(parse("*** Begin Patch\n*** Update File: file.txt\n import foo\n+bar\n*** End Patch")).toEqual([
|
||||
{
|
||||
@@ -413,11 +448,14 @@ describe("Patch", () => {
|
||||
|
||||
test("rejects invalid add and delete lines", () => {
|
||||
expect(() => parse("*** Begin Patch\n*** Add File: file.txt\nbad\n*** End Patch")).toThrow(
|
||||
"Invalid hunk at line 3: 'bad' is not a valid hunk header",
|
||||
"Invalid hunk at line 3: Invalid Add File line for 'file.txt': expected a line starting with '+', got 'bad'",
|
||||
)
|
||||
expect(() => parse("*** Begin Patch\n*** Delete File: file.txt\nbad\n*** End Patch")).toThrow(
|
||||
"Invalid hunk at line 3: 'bad' is not a valid hunk header",
|
||||
"Invalid hunk at line 3: Unexpected line after Delete File 'file.txt': 'bad'. Delete hunks do not contain body lines",
|
||||
)
|
||||
expect(() =>
|
||||
parse("*** Begin Patch\n*** Delete File: file.txt\n*** Frobnicate File: next.txt\n*** End Patch"),
|
||||
).toThrow("Invalid hunk at line 3: '*** Frobnicate File: next.txt' is not a valid hunk header")
|
||||
})
|
||||
|
||||
test("rejects an empty update hunk", () => {
|
||||
@@ -478,6 +516,6 @@ describe("Patch", () => {
|
||||
}
|
||||
expect(() =>
|
||||
parse("*** Begin Patch\n*** Update File: old.txt\n*** Move to: \n@@\n-old\n+new\n*** End Patch"),
|
||||
).toThrow("Invalid hunk at line 3: '*** Move to:' is not a valid hunk header")
|
||||
).toThrow("Invalid hunk at line 3: Move destination for 'old.txt' must not be empty")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -300,8 +300,8 @@ describe("PluginV2", () => {
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
|
||||
"plain",
|
||||
"context7_look_up",
|
||||
"plain",
|
||||
"execute",
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -49,7 +49,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop" },
|
||||
usage: {
|
||||
inputTokens: 15,
|
||||
outputTokens: 6,
|
||||
@@ -60,7 +60,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -59,8 +59,8 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
LLMEvent.textStart({ id: "generate" }),
|
||||
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
|
||||
LLMEvent.textEnd({ id: "generate" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 100, outputTokens: 10 } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
if (!response) throw new Error("Incomplete generate response")
|
||||
return response
|
||||
@@ -97,6 +97,7 @@ const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||
const tools = Layer.mock(ToolRegistry.Service, {
|
||||
snapshot: () =>
|
||||
Effect.succeed({
|
||||
codeModeInstructions: "Captured Code Mode catalog",
|
||||
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
|
||||
execute: () => Effect.die(new Error("unused")),
|
||||
}),
|
||||
@@ -285,13 +286,14 @@ it.effect("generates from fresh settled Session context without durable mutation
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
|
||||
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
|
||||
expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } })
|
||||
expect(
|
||||
requests[0]?.messages.flatMap((message) =>
|
||||
message.role === "system"
|
||||
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
: [],
|
||||
),
|
||||
).toEqual(["Changed context"])
|
||||
const instructionUpdates = requests[0]?.messages.flatMap((message) =>
|
||||
message.role === "system"
|
||||
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
: [],
|
||||
)
|
||||
expect(instructionUpdates).toHaveLength(1)
|
||||
expect(instructionUpdates?.[0]).toContain("Changed context")
|
||||
expect(instructionUpdates?.[0]).toContain("Captured Code Mode catalog")
|
||||
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
|
||||
expect(
|
||||
requests[0]?.messages.flatMap((message) =>
|
||||
|
||||
@@ -255,7 +255,7 @@ test("success event data can carry provider-executed result state", () => {
|
||||
test("step finish records settlement without publishing step ended", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
|
||||
|
||||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
@@ -268,7 +268,7 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
publisher.publish(
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: "content-filter",
|
||||
reason: { normalized: "content-filter" },
|
||||
usage: {
|
||||
nonCachedInputTokens: 8,
|
||||
outputTokens: 3,
|
||||
@@ -311,7 +311,7 @@ test("content-filter finish preserves partial streamed text and never ends the s
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text" }),
|
||||
LLMEvent.textDelta({ id: "text", text: "Partial" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }),
|
||||
],
|
||||
(event) => publisher.publish(event),
|
||||
{ discard: true },
|
||||
|
||||
@@ -121,6 +121,33 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("canonicalizes effective definitions and keeps Code Mode last", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const tool = make()
|
||||
const capture = (registrations: Parameters<typeof service.registerBatch>[0]) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* service.registerBatch(registrations)
|
||||
return (yield* service.snapshot()).definitions
|
||||
}),
|
||||
)
|
||||
const first = yield* capture([
|
||||
{ tools: { zeta: tool, alpha: tool }, options: { codemode: false } },
|
||||
{ tools: { beta: tool }, options: { namespace: "alpha", codemode: false } },
|
||||
{ tools: { echo: tool } },
|
||||
])
|
||||
const second = yield* capture([
|
||||
{ tools: { echo: tool } },
|
||||
{ tools: { beta: tool }, options: { namespace: "alpha", codemode: false } },
|
||||
{ tools: { alpha: tool, zeta: tool }, options: { codemode: false } },
|
||||
])
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(first.map((definition) => definition.name)).toEqual(["alpha", "alpha_beta", "zeta", "execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
@@ -142,7 +169,7 @@ describe("ToolRegistry", () => {
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
]),
|
||||
).toEqual([])
|
||||
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
|
||||
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["bash", "question"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -506,6 +533,7 @@ describe("ToolRegistry", () => {
|
||||
.pipe(Scope.provide(scope))
|
||||
const toolSet = yield* service.snapshot()
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
expect(toolSet.codeModeInstructions).toContain("tools.echo")
|
||||
expect(execute?.description).toContain("confined Code Mode runtime")
|
||||
expect(execute?.description).not.toContain("Echo text")
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
@@ -43,6 +43,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionUsage } from "@opencode-ai/core/session/usage"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { CodeMode } from "@opencode-ai/core/codemode"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
|
||||
@@ -119,8 +120,8 @@ const client = Layer.succeed(
|
||||
const reply = {
|
||||
stop: () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
],
|
||||
text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents,
|
||||
textWithUsage: (text: string, id: string, inputTokens: number) =>
|
||||
@@ -136,8 +137,8 @@ const reply = {
|
||||
tool: (id: string, name: string, input: unknown) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id, name, input }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
],
|
||||
}
|
||||
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
||||
@@ -368,6 +369,12 @@ const pluginSupervisor = Layer.succeed(
|
||||
flush: Effect.suspend(() => pluginFlushHook),
|
||||
}),
|
||||
)
|
||||
let codeModeMaterializations: ReadonlyArray<CodeMode.Materialization> = []
|
||||
let codeModeMaterializationCount = 0
|
||||
const codeMode = Layer.mock(CodeMode.Service, {
|
||||
register: () => Effect.void,
|
||||
materialize: () => Effect.sync(() => codeModeMaterializations[codeModeMaterializationCount++] ?? {}),
|
||||
})
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.succeed(undefined),
|
||||
@@ -405,6 +412,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[McpInstructions.node, mcpInstructions],
|
||||
[ToolOutputStore.node, toolOutputStore],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[CodeMode.node, codeMode],
|
||||
])
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
@@ -464,6 +472,7 @@ const it = testEffect(
|
||||
[Config.node, config],
|
||||
[ToolOutputStore.node, toolOutputStore],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[CodeMode.node, codeMode],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -512,6 +521,8 @@ const setup = Effect.gen(function* () {
|
||||
systemLoadHook = Effect.void
|
||||
modelResolveHook = Effect.void
|
||||
pluginFlushHook = Effect.void
|
||||
codeModeMaterializations = []
|
||||
codeModeMaterializationCount = 0
|
||||
currentModel = model
|
||||
skillBaselines.clear()
|
||||
responses = undefined
|
||||
@@ -682,8 +693,8 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
|
||||
completeEvents: [
|
||||
...partialEvents,
|
||||
LLMEvent.textEnd({ id }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
],
|
||||
expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
|
||||
expectedContent,
|
||||
@@ -702,8 +713,8 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
|
||||
completeEvents: [
|
||||
...partialEvents,
|
||||
LLMEvent.reasoningEnd({ id }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
],
|
||||
expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
|
||||
expectedContent,
|
||||
@@ -823,6 +834,45 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("uses one Code Mode materialization per request for instructions and execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const executed: string[] = []
|
||||
const execute = (name: string) =>
|
||||
Tool.make({
|
||||
description: `Execute ${name}`,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.sync(() => executed.push(name)).pipe(Effect.as({ output: name })),
|
||||
})
|
||||
const session = yield* setup
|
||||
codeModeMaterializations = [
|
||||
{ instructions: "Code Mode catalog A", tool: execute("A") },
|
||||
{ instructions: "Code Mode catalog B", tool: execute("B") },
|
||||
{ instructions: "Code Mode catalog C", tool: execute("C") },
|
||||
{ instructions: "Code Mode catalog D", tool: execute("D") },
|
||||
]
|
||||
yield* admit(session, "Use Code Mode")
|
||||
responses = [reply.tool("call-execute", "execute", {}), reply.stop()]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(codeModeMaterializationCount).toBe(2)
|
||||
expect(requests[0]?.system.some((part) => part.text.includes("Code Mode catalog A"))).toBe(true)
|
||||
expect(requests[0]?.system.some((part) => part.text.includes("Code Mode catalog B"))).toBe(false)
|
||||
expect(requests[0]?.tools.find((tool) => tool.name === "execute")?.description).toBe("Execute A")
|
||||
expect(executed).toEqual(["A"])
|
||||
expect(requests[1]?.tools.find((tool) => tool.name === "execute")?.description).toBe("Execute B")
|
||||
expect(
|
||||
requests[1]?.messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.some((part) => part.type === "text" && part.text.includes("Code Mode catalog B")),
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies session context hooks without exposing unavailable tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -999,8 +1049,8 @@ describe("SessionRunnerLLM", () => {
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-reloaded", name: "reloaded", input: {} }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
@@ -1099,7 +1149,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail"])
|
||||
expect(requests[0]?.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "First" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Second" }] },
|
||||
@@ -2377,7 +2427,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: "tool-calls",
|
||||
reason: { normalized: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
nonCachedInputTokens: 8,
|
||||
@@ -2386,13 +2436,13 @@ describe("SessionRunnerLLM", () => {
|
||||
cacheReadInputTokens: 2,
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Use tools" },
|
||||
{
|
||||
@@ -2535,8 +2585,8 @@ describe("SessionRunnerLLM", () => {
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
@@ -2600,8 +2650,8 @@ describe("SessionRunnerLLM", () => {
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
@@ -2648,8 +2698,8 @@ describe("SessionRunnerLLM", () => {
|
||||
),
|
||||
])
|
||||
const final = Stream.fromIterable([
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
])
|
||||
responseStream = Stream.concat(
|
||||
initial,
|
||||
@@ -3605,7 +3655,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* admit(session, "Reject permission")
|
||||
responses = [
|
||||
reply.tool("call-permission", "permissionfail", {}),
|
||||
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })],
|
||||
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
@@ -3954,10 +4004,10 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.textDelta({ id: "partial", text: "Partial" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: "content-filter",
|
||||
reason: { normalized: "content-filter" },
|
||||
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
|
||||
}),
|
||||
LLMEvent.finish({ reason: "content-filter" }),
|
||||
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
|
||||
]
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response")
|
||||
@@ -3990,8 +4040,8 @@ describe("SessionRunnerLLM", () => {
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
|
||||
LLMEvent.finish({ reason: "content-filter" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
@@ -4282,8 +4332,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw,
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
@@ -4379,8 +4429,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
@@ -4421,8 +4471,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
@@ -4530,8 +4580,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
]
|
||||
responses = [
|
||||
malformed("call-first"),
|
||||
@@ -4565,8 +4615,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
]
|
||||
responses = [malformed("call-first"), malformed("call-at-limit")]
|
||||
|
||||
@@ -4727,8 +4777,8 @@ describe("SessionRunnerLLM", () => {
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
hostedCall("call-hosted-clean-end", "effect"),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
@@ -4852,8 +4902,8 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.textStart({ id: "text-2" }),
|
||||
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
|
||||
LLMEvent.textEnd({ id: "text-2" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
@@ -4906,8 +4956,8 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }),
|
||||
hostedCall("call-parsed", "hello"),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -47,7 +47,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop" },
|
||||
usage: {
|
||||
inputTokens: 15,
|
||||
outputTokens: 6,
|
||||
@@ -58,7 +58,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: "stop",
|
||||
reason: { normalized: "stop" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Schema } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -28,6 +29,8 @@ const sessionID = SessionV2.ID.make("ses_patch_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let denyAction: string | undefined
|
||||
let failRemoveTarget: string | undefined
|
||||
let failRemoveErrorTarget: string | undefined
|
||||
let failWriteTarget: string | undefined
|
||||
let readsBeforeEditApproval = 0
|
||||
let editApproved = false
|
||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
||||
@@ -65,6 +68,8 @@ const reset = () => {
|
||||
assertions.length = 0
|
||||
denyAction = undefined
|
||||
failRemoveTarget = undefined
|
||||
failRemoveErrorTarget = undefined
|
||||
failWriteTarget = undefined
|
||||
readsBeforeEditApproval = 0
|
||||
editApproved = false
|
||||
afterEditApproval = () => Effect.void
|
||||
@@ -82,8 +87,33 @@ const filesystem = Layer.effect(
|
||||
}).pipe(Effect.andThen(fs.readFile(target))),
|
||||
remove: (target, options) => {
|
||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
|
||||
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
|
||||
return Effect.fail(
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "FileSystem",
|
||||
method: "remove",
|
||||
description: "forced remove failure",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fs.remove(target, options)
|
||||
},
|
||||
writeWithDirs: (target, content, mode) => {
|
||||
if (failWriteTarget && path.basename(target) === failWriteTarget) {
|
||||
return Effect.fail(
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "FileSystem",
|
||||
method: "writeWithDirs",
|
||||
description: "forced write failure",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fs.writeWithDirs(target, content, mode)
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
@@ -302,6 +332,27 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("moves a file without changing its contents", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(directory, "old.txt")
|
||||
const destination = path.join(directory, "moved.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(source, "same\n"))
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n same\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "Success. Updated the following files:\nM moved.txt" }],
|
||||
})
|
||||
expect(yield* exists(source)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("same\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("moves a symlink without deleting its target", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -451,10 +502,17 @@ describe("PatchTool", () => {
|
||||
it.live("rejects an empty patch", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "patch rejected: empty patch" },
|
||||
})
|
||||
for (const patchText of [
|
||||
"*** Begin Patch\n*** End Patch",
|
||||
" *** Begin Patch \n *** End Patch ",
|
||||
"<<EOF\n*** Begin Patch\n*** End Patch\nEOF",
|
||||
"*** Begin Patch\n*** Environment ID: remote\n*** End Patch",
|
||||
]) {
|
||||
expect(yield* executeTool(registry, call(patchText))).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "patch rejected: empty patch" },
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -525,7 +583,10 @@ describe("PatchTool", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { message: expect.stringContaining("Failed to find expected lines") },
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "patch verification failed: Failed to find expected lines in unchanged.txt:\nmissing",
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
|
||||
}),
|
||||
@@ -569,12 +630,83 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a delete when the target file is missing", () =>
|
||||
it.live("identifies a missing delete target", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
|
||||
).toMatchObject({ status: "error", error: { message: expect.stringContaining("patch verification failed") } })
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "patch verification failed: Failed to delete missing.txt: file does not exist",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports the failing destination and filesystem error", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "old.txt"), "before\n"))
|
||||
failWriteTarget = "new.txt"
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Failed to write new.txt: forced write failure" },
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "old.txt"), "utf8"))).toBe("before\n")
|
||||
expect(yield* exists(path.join(directory, "new.txt"))).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports the successful prefix and filesystem error", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
failWriteTarget = "second.txt"
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Add File: first.txt\n+first\n*** Add File: second.txt\n+second\n*** End Patch"),
|
||||
),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "Failed to write second.txt: forced write failure. Completed before failure: first.txt",
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "first.txt"), "utf8"))).toBe("first\n")
|
||||
expect(yield* exists(path.join(directory, "second.txt"))).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports a destination written before move removal fails", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "old.txt"), "before\n"))
|
||||
failRemoveErrorTarget = "old.txt"
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "Wrote new.txt but failed to remove old.txt: forced remove failure",
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "old.txt"), "utf8"))).toBe("before\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "new.txt"), "utf8"))).toBe("after\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -628,7 +760,7 @@ describe("PatchTool", () => {
|
||||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ status: "error" })
|
||||
).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
|
||||
expect(readsBeforeEditApproval).toBe(0)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
|
||||
@@ -645,6 +777,24 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves edit permission rejection", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = path.join(directory, "target.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(target, "before\n"))
|
||||
denyAction = "edit"
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: target.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("treats a sibling path inside the project worktree as internal", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -120,21 +120,13 @@ describe("WriteTool", () => {
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
|
||||
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
|
||||
expect(settled).toMatchObject({
|
||||
expect(settled).toEqual({
|
||||
status: "completed",
|
||||
output: {
|
||||
operation: "write",
|
||||
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
existed: false,
|
||||
files: [
|
||||
{
|
||||
file: "src/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
|
||||
})
|
||||
@@ -164,21 +156,7 @@ describe("WriteTool", () => {
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
||||
expect(settled.output).toMatchObject({
|
||||
resource: "existing.txt",
|
||||
existed: true,
|
||||
files: [
|
||||
{
|
||||
file: "existing.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
const output = settled.output as WriteTool.Output
|
||||
expect(output.files[0]?.patch).toContain("-before")
|
||||
expect(output.files[0]?.patch).toContain("+after")
|
||||
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
|
||||
"after",
|
||||
)
|
||||
|
||||
@@ -62,7 +62,6 @@ import parsers from "../../parsers-config"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
@@ -2694,56 +2693,28 @@ function Shell(props: ToolProps) {
|
||||
}
|
||||
|
||||
function Write(props: ToolProps) {
|
||||
const ctx = use()
|
||||
const { themeV2, syntax } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const code = createMemo(() => {
|
||||
return stringValue(props.input.content) ?? ""
|
||||
})
|
||||
const file = createMemo(() => parseApplyPatchFiles(props.metadata.files)[0])
|
||||
const patch = createMemo(
|
||||
() => file()?.patch ?? createTwoFilesPatch("", stringValue(props.input.path) ?? "", "", code()),
|
||||
)
|
||||
const complete = createMemo(() => props.part.state.status === "completed")
|
||||
const view = createMemo(() => {
|
||||
if (ctx.config.diffs?.view === "unified") return "unified"
|
||||
if (ctx.config.diffs?.view === "split") return "split"
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={complete()}>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
<BlockTool
|
||||
path={{
|
||||
label: props.metadata.existed === false ? "# Created" : "# Wrote",
|
||||
value: pathFormatter.format(stringValue(props.input.path)),
|
||||
}}
|
||||
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
|
||||
part={props.part}
|
||||
>
|
||||
<Show when={code() || file()?.additions || file()?.deletions}>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={patch()}
|
||||
view={view()}
|
||||
filetype={filetype(stringValue(props.input.path))}
|
||||
syntaxStyle={syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode={ctx.diffWrapMode()}
|
||||
fg={themeV2.text.default}
|
||||
addedBg={themeV2.diff.background.added}
|
||||
removedBg={themeV2.diff.background.removed}
|
||||
contextBg={themeV2.diff.background.context}
|
||||
addedSignColor={themeV2.diff.highlight.added}
|
||||
removedSignColor={themeV2.diff.highlight.removed}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
||||
lineNumberBg={themeV2.diff.background.context}
|
||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
|
||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<line_number fg={themeV2.text.subdued} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
fg={themeV2.text.default}
|
||||
filetype={filetype(stringValue(props.input.path))}
|
||||
syntaxStyle={syntax()}
|
||||
content={code()}
|
||||
/>
|
||||
</line_number>
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
|
||||
</BlockTool>
|
||||
</Match>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
|
||||
}
|
||||
if (header.startsWith("*** Add File: ")) {
|
||||
const path = header.slice("*** Add File: ".length).trim()
|
||||
const parsed = parseAdd(lines, index + 1, end)
|
||||
const parsed = parseAdd(lines, index + 1, end, path)
|
||||
if ("error" in parsed) return Result.fail(parsed.error)
|
||||
hunks.push({ type: "add", path, contents: parsed.content })
|
||||
index = parsed.next
|
||||
@@ -77,6 +77,19 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
|
||||
}
|
||||
if (header.startsWith("*** Delete File: ")) {
|
||||
const path = header.slice("*** Delete File: ".length).trim()
|
||||
const next = lines[index + 1]?.trim()
|
||||
if (index + 1 < end && next !== undefined && !isBoundary(next)) {
|
||||
if (next.startsWith("*** ")) {
|
||||
return Result.fail(new InvalidHunkError({ line: next, lineNumber: index + 2 }))
|
||||
}
|
||||
return Result.fail(
|
||||
new InvalidHunkError({
|
||||
line: next,
|
||||
lineNumber: index + 2,
|
||||
reason: `Unexpected line after Delete File '${path}': '${next}'. Delete hunks do not contain body lines`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
hunks.push({ type: "delete", path })
|
||||
index++
|
||||
continue
|
||||
@@ -90,7 +103,13 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
|
||||
if (move === "*** Move to:" || move?.startsWith("*** Move to: ")) {
|
||||
movePath = move.slice("*** Move to: ".length).trim()
|
||||
if (!movePath) {
|
||||
return Result.fail(new InvalidHunkError({ line: lines[next]!.trim(), lineNumber: next + 1 }))
|
||||
return Result.fail(
|
||||
new InvalidHunkError({
|
||||
line: lines[next]!.trim(),
|
||||
lineNumber: next + 1,
|
||||
reason: `Move destination for '${path}' must not be empty`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
next++
|
||||
}
|
||||
@@ -126,12 +145,20 @@ function parseAdd(
|
||||
lines: ReadonlyArray<string>,
|
||||
start: number,
|
||||
end: number,
|
||||
path: string,
|
||||
): { content: string; next: number } | { error: InvalidHunkError } {
|
||||
const content: string[] = []
|
||||
let index = start
|
||||
while (index < end && !isBoundary(lines[index]!.trim())) {
|
||||
if (!lines[index]!.startsWith("+")) {
|
||||
return { error: new InvalidHunkError({ line: lines[index]!.trim(), lineNumber: index + 1 }) }
|
||||
const line = lines[index]!.trim()
|
||||
return {
|
||||
error: new InvalidHunkError({
|
||||
line,
|
||||
lineNumber: index + 1,
|
||||
reason: `Invalid Add File line for '${path}': expected a line starting with '+', got '${line}'`,
|
||||
}),
|
||||
}
|
||||
}
|
||||
content.push(lines[index]!.slice(1))
|
||||
index++
|
||||
@@ -303,6 +330,11 @@ function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks:
|
||||
if (newLines.at(-1) === "") newLines = newLines.slice(0, -1)
|
||||
found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
|
||||
}
|
||||
if (found === -1 && chunk.oldLines.every((line) => line === "")) {
|
||||
const expected =
|
||||
chunk.oldLines.length === 1 ? "an expected blank line" : `${chunk.oldLines.length} consecutive blank lines`
|
||||
throw new Error(`Failed to find ${expected} in ${path}`)
|
||||
}
|
||||
if (found === -1) throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`)
|
||||
replacements.push([found, oldLines.length, newLines])
|
||||
lineIndex = found + oldLines.length
|
||||
|
||||
Reference in New Issue
Block a user