mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 11:46:17 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b887030e6 | ||
|
|
65c5c7e3f6 | ||
|
|
ee69a91f26 | ||
|
|
7456598cde | ||
|
|
c228fc4886 | ||
|
|
bb3f4cc3c7 | ||
|
|
8d9727be9f | ||
|
|
6401eeaea0 | ||
|
|
e7ecee5df2 |
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: ideal-pseudocode
|
||||
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
|
||||
---
|
||||
|
||||
# Ideal Pseudocode
|
||||
|
||||
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
|
||||
|
||||
## Loop
|
||||
|
||||
One function per round. Never touch code before the user picks a direction.
|
||||
|
||||
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
|
||||
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
|
||||
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
|
||||
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
|
||||
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
|
||||
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
|
||||
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
|
||||
8. **Offer the next target** and stop.
|
||||
|
||||
## Style
|
||||
|
||||
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
|
||||
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
|
||||
- Prefer early return and `if` chains for control flow; matchers are for producing values.
|
||||
- Compare names against the question each caller is actually asking; rename toward the domain noun.
|
||||
|
||||
## Example round
|
||||
|
||||
Every round should read like this (abridged from a real one, on an Effect step-runner):
|
||||
|
||||
> `runStep` it is. The ideal pseudocode:
|
||||
>
|
||||
> ```ts
|
||||
> // One logical step: one settled model call, however many attempts it takes.
|
||||
> // An attempt can end without settling in two ways:
|
||||
> // transient provider failure -> retry the same call, same assistant message
|
||||
> // compaction rewrote history -> rebuild the request and call again
|
||||
> function runStep(session, promotable, step) {
|
||||
> while (true) {
|
||||
> const result = callModel(session, promotable, step)
|
||||
>
|
||||
> if (result.completed) return result
|
||||
>
|
||||
> if (result.retryable) {
|
||||
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
|
||||
> promotable = none // never re-promote on a second attempt
|
||||
> continue
|
||||
> }
|
||||
>
|
||||
> // compaction restarted the step: fresh request from rewritten history
|
||||
> promotable = none
|
||||
> step = result.step
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Comparing against the real thing, three deltas:
|
||||
>
|
||||
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
|
||||
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
|
||||
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
|
||||
>
|
||||
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
|
||||
>
|
||||
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
|
||||
@@ -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" } }],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -75,6 +75,15 @@ const AnthropicThinkingBlock = Schema.Struct({
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
})
|
||||
|
||||
// Safety-filtered thinking arrives as an opaque encrypted `data` payload with
|
||||
// no visible text. It must round-trip verbatim so multi-turn thinking + tool
|
||||
// use conversations keep their reasoning continuity.
|
||||
const AnthropicRedactedThinkingBlock = Schema.Struct({
|
||||
type: Schema.tag("redacted_thinking"),
|
||||
data: Schema.String,
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
})
|
||||
|
||||
const AnthropicToolUseBlock = Schema.Struct({
|
||||
type: Schema.tag("tool_use"),
|
||||
id: Schema.String,
|
||||
@@ -136,6 +145,7 @@ type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||
const AnthropicAssistantBlock = Schema.Union([
|
||||
AnthropicTextBlock,
|
||||
AnthropicThinkingBlock,
|
||||
AnthropicRedactedThinkingBlock,
|
||||
AnthropicToolUseBlock,
|
||||
AnthropicServerToolUseBlock,
|
||||
AnthropicServerToolResultBlock,
|
||||
@@ -214,6 +224,9 @@ const AnthropicStreamBlock = Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// redacted_thinking blocks arrive whole in content_block_start with the
|
||||
// encrypted payload in `data`; there is no streaming delta sequence.
|
||||
data: Schema.optional(Schema.String),
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
// *_tool_result blocks arrive whole as content_block_start (no streaming
|
||||
// delta) with the structured payload in `content` and the originating
|
||||
@@ -287,6 +300,12 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||
const anthropic = metadata?.anthropic
|
||||
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -472,11 +491,16 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
content.push({
|
||||
type: "thinking",
|
||||
thinking: part.text,
|
||||
signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata),
|
||||
})
|
||||
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
|
||||
// thinking; only signature-less parts carrying redactedData
|
||||
// round-trip as opaque redacted_thinking blocks.
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ type: "redacted_thinking", data: redactedData })
|
||||
continue
|
||||
}
|
||||
content.push({ type: "thinking", thinking: part.text, signature })
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -601,7 +625,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"
|
||||
@@ -747,6 +771,25 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
]
|
||||
}
|
||||
|
||||
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
|
||||
// payload as `redactedData` metadata (same model as Vercel's
|
||||
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
|
||||
if (block.type === "redacted_thinking" && block.data) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
anthropicMetadata({ redactedData: block.data }),
|
||||
),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const result = serverToolResultEvent(block)
|
||||
if (!result) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
@@ -836,7 +879,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")
|
||||
|
||||
@@ -409,6 +409,34 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
|
||||
{ type: "reasoning", text: "visible", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "redacted_thinking", data: "opaque_1" },
|
||||
{ type: "thinking", thinking: "visible", signature: "sig_1" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -448,12 +476,149 @@ 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("parses redacted thinking into empty reasoning with redactedData metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "redacted_thinking", data: "opaque_1" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-start")).toMatchObject({
|
||||
providerMetadata: { anthropic: { redactedData: "opaque_1" } },
|
||||
})
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
|
||||
{ type: "text", text: "Hello" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips streamed redacted thinking with tool use into a continuation request", () =>
|
||||
Effect.gen(function* () {
|
||||
// Anthropic types `redacted_thinking.data` as an opaque string. Its
|
||||
// contents are provider-owned and must be replayed without inspection.
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "redacted_thinking", data: redactedData },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Say hello."),
|
||||
response.message,
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Say hello." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "redacted_thinking", data: redactedData },
|
||||
{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_1",
|
||||
content: "sunny",
|
||||
is_error: undefined,
|
||||
cache_control: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
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 +668,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 +845,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", () => {
|
||||
|
||||
@@ -72,6 +72,7 @@ Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: {
|
||||
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
|
||||
|
||||
runtime.catalog() // structured tool descriptions
|
||||
runtime.instructions() // model-facing syntax and tool guide
|
||||
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
|
||||
```
|
||||
|
||||
@@ -144,13 +145,13 @@ safe refusal to the model; its optional cause remains private.
|
||||
|
||||
## Discovery
|
||||
|
||||
`runtime.catalog()` returns structured descriptors — exact path, description, and generated TypeScript signature — for
|
||||
every visible tool. Hosts render their own model-facing instructions from these descriptors; `CodeMode.searchSignature`
|
||||
and `CodeMode.toolExpression(path)` supply the exact callable forms.
|
||||
Generated instructions contain a tool catalog with a default budget of 2,000 estimated tokens. Configure it with
|
||||
`discovery: { catalogBudget }`. Every namespace remains visible, and the instructions say whether the catalog is
|
||||
complete or partial.
|
||||
|
||||
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
|
||||
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
|
||||
`maxToolCalls`.
|
||||
The synchronous `search(...)` built-in is always available and advertised when the catalog is partial. It supports
|
||||
exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full
|
||||
signatures. Search counts toward `maxToolCalls`.
|
||||
|
||||
## Execution Limits
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import type { Tools } from "./tools.js"
|
||||
|
||||
/** A tool call admitted during an execution. */
|
||||
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
|
||||
/** Signature-construction helpers for host-owned catalog instructions. */
|
||||
export { searchSignature, toolExpression } from "./tool-runtime.js"
|
||||
|
||||
/** Resource budgets enforced independently during each CodeMode program execution. */
|
||||
export type ExecutionLimits = {
|
||||
@@ -24,6 +22,12 @@ export type ExecutionLimits = {
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
/** Controls how much of the tool catalog is inlined in agent instructions. */
|
||||
export type DiscoveryOptions = {
|
||||
/** Approximate token budget (chars/4, default 2000) for full catalog entries. */
|
||||
readonly catalogBudget?: number
|
||||
}
|
||||
|
||||
export type ResolvedExecutionLimits = {
|
||||
readonly timeoutMs: number | undefined
|
||||
readonly maxToolCalls: number | undefined
|
||||
@@ -48,7 +52,10 @@ export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
|
||||
export type DataValue = Schema.Json
|
||||
|
||||
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code">
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code"> & {
|
||||
/** Progressive-disclosure configuration for the agent-facing tool catalog. */
|
||||
readonly discovery?: DiscoveryOptions
|
||||
}
|
||||
|
||||
/** Schema for a host tool input containing CodeMode source. */
|
||||
export const Input = Schema.Struct({ code: Schema.String })
|
||||
@@ -109,6 +116,7 @@ export type Result = typeof Result.Type
|
||||
/** Reusable confined runtime over explicit tools. */
|
||||
export type Runtime<R = never> = {
|
||||
readonly catalog: () => ReadonlyArray<ToolDescription>
|
||||
readonly instructions: () => string
|
||||
readonly execute: (code: string) => Effect.Effect<Result, never, R>
|
||||
}
|
||||
|
||||
@@ -139,10 +147,11 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
): Runtime<Services<Provided>> => {
|
||||
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
const prepared = ToolRuntime.prepare(tools)
|
||||
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
|
||||
|
||||
return {
|
||||
catalog: () => prepared.catalog,
|
||||
instructions: () => prepared.instructions,
|
||||
execute: (code) => executeWithLimits<Provided>({ ...options, code }, limits, prepared.searchIndex),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * as CodeMode from "./codemode.js"
|
||||
export * as Tool from "./tool.js"
|
||||
export * as OpenAPI from "./openapi/index.js"
|
||||
export { searchSignature, toolExpression } from "./codemode.js"
|
||||
export { ToolError, toolError } from "./tool-error.js"
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
CodeModeURLSearchParams,
|
||||
} 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, []>
|
||||
|
||||
type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
|
||||
@@ -67,6 +70,7 @@ export type ToolDescription = {
|
||||
|
||||
export type SafeObject = Record<string, unknown>
|
||||
|
||||
const defaultCatalogBudget = 2_000
|
||||
const defaultSearchLimit = 10
|
||||
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
||||
const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
@@ -86,7 +90,7 @@ const SearchOutput = Schema.Struct({
|
||||
remaining: NonNegativeInt,
|
||||
next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })),
|
||||
})
|
||||
export const toolExpression = (path: string) =>
|
||||
const toolExpression = (path: string) =>
|
||||
"tools" +
|
||||
path
|
||||
.split(".")
|
||||
@@ -323,15 +327,19 @@ 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>
|
||||
readonly instructions: string
|
||||
readonly searchIndex: ReadonlyArray<SearchEntry>
|
||||
}
|
||||
|
||||
@@ -399,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 }) => ({
|
||||
@@ -415,12 +423,17 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
}),
|
||||
})
|
||||
|
||||
/** Exact callable signature of the built-in `search` function, for host-owned instructions. */
|
||||
export const searchSignature = (() => {
|
||||
const searchSignature = (() => {
|
||||
const tool = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
|
||||
})()
|
||||
|
||||
const catalogLine = (tool: ToolDescription) => {
|
||||
const line = tool.description.split("\n", 1)[0]!.trim()
|
||||
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
|
||||
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
||||
}
|
||||
|
||||
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
|
||||
description,
|
||||
namespace: path.split(".", 1)[0]!,
|
||||
@@ -438,10 +451,146 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
|
||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
|
||||
|
||||
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
|
||||
// Budget signatures round-robin so every namespace remains visible.
|
||||
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
||||
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
||||
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
|
||||
}
|
||||
const visible = visibleTools(tools)
|
||||
const described = visible.map(({ description }) => description)
|
||||
|
||||
const namespaces = new Map<string, Array<ToolDescription>>()
|
||||
for (const tool of described) {
|
||||
const [namespace = tool.path] = tool.path.split(".")
|
||||
const group = namespaces.get(namespace) ?? []
|
||||
group.push(tool)
|
||||
namespaces.set(namespace, group)
|
||||
}
|
||||
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)) || compareText(left.path, right.path),
|
||||
),
|
||||
}))
|
||||
let used = 0
|
||||
let active = selections.filter((selection) => selection.queue.length > 0)
|
||||
while (active.length > 0) {
|
||||
const stillActive: typeof active = []
|
||||
for (const selection of active) {
|
||||
const tool = selection.queue[0]!
|
||||
const cost = estimateTokens(catalogLine(tool))
|
||||
if (used + cost > catalogBudget) continue
|
||||
selection.queue.shift()
|
||||
selection.picked.add(tool)
|
||||
used += cost
|
||||
if (selection.queue.length > 0) stillActive.push(selection)
|
||||
}
|
||||
active = stillActive
|
||||
}
|
||||
const shown = new Map<string, ReadonlySet<ToolDescription>>(
|
||||
selections.map(({ namespace, picked }) => [namespace, picked]),
|
||||
)
|
||||
const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0)
|
||||
const complete = totalShown === described.length
|
||||
|
||||
const empty = described.length === 0
|
||||
|
||||
const intro = [
|
||||
empty
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
|
||||
: complete
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed below; surrounding agent tools are not available."
|
||||
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed or searchable below; surrounding agent tools are not available.",
|
||||
...(empty
|
||||
? []
|
||||
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
|
||||
]
|
||||
|
||||
const workflow = empty
|
||||
? []
|
||||
: [
|
||||
"",
|
||||
"## Workflow",
|
||||
"",
|
||||
...(complete
|
||||
? [
|
||||
"1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
|
||||
"2. Call it using the exact signature shown: `const result = await tools.<namespace>.<tool>(input)`; bracket notation and quotes are part of the path.",
|
||||
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
|
||||
]
|
||||
: [
|
||||
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
|
||||
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
|
||||
]),
|
||||
]
|
||||
|
||||
const rules = empty
|
||||
? []
|
||||
: [
|
||||
"",
|
||||
"## Rules",
|
||||
"",
|
||||
complete
|
||||
? "- Only tools listed here are available; surrounding agent tools are not implicitly exposed."
|
||||
: "- Only tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
|
||||
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
|
||||
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
|
||||
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
|
||||
"- Execution ends when the program returns; pending promises are interrupted, so await every call whose completion matters.",
|
||||
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
|
||||
...(complete
|
||||
? []
|
||||
: [
|
||||
'- Browse one namespace: `search({ query: "", namespace: "<name>" })`.',
|
||||
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
|
||||
]),
|
||||
]
|
||||
|
||||
const language = [
|
||||
"",
|
||||
"## Language",
|
||||
"",
|
||||
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
|
||||
"Modules/imports, classes, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.",
|
||||
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
|
||||
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
|
||||
]
|
||||
|
||||
const toolSection: Array<string> = [""]
|
||||
if (empty) {
|
||||
toolSection.push("## Available tools", "", "No tools are currently available.")
|
||||
} else {
|
||||
toolSection.push(
|
||||
complete
|
||||
? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
|
||||
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with search(...))`,
|
||||
"",
|
||||
)
|
||||
for (const [namespace, group] of ordered) {
|
||||
const picked = shown.get(namespace)!
|
||||
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
|
||||
const label =
|
||||
picked.size === group.length
|
||||
? count
|
||||
: picked.size === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${picked.size} shown`
|
||||
toolSection.push(`- ${namespace} (${label})`)
|
||||
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
|
||||
}
|
||||
if (!complete) {
|
||||
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection]
|
||||
return {
|
||||
catalog: visible.map(({ description }) => description),
|
||||
catalog: described,
|
||||
instructions: lines.join("\n"),
|
||||
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
|
||||
}
|
||||
}
|
||||
@@ -463,7 +612,7 @@ const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> =>
|
||||
const node = lookup(root, segments)
|
||||
if (node === undefined) {
|
||||
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [
|
||||
"The tool may have been removed or renamed. Use search to find available tools.",
|
||||
"Use search({ query }) to find available described tools.",
|
||||
])
|
||||
}
|
||||
if (node.tool === undefined) {
|
||||
@@ -536,11 +685,7 @@ export const make = <R>(
|
||||
const input = yield* Effect.try({
|
||||
try: () => decodeToolInput(tool, externalArgs[0]),
|
||||
catch: (cause) =>
|
||||
new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
`Invalid input for tool '${name}': ${String(cause)}`,
|
||||
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
|
||||
),
|
||||
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
})
|
||||
const index = yield* recordAndObserve(name, input)
|
||||
return yield* observeEnd(
|
||||
|
||||
@@ -29,7 +29,7 @@ export type JsonSchema = {
|
||||
/** Either a validating Effect Schema or a render-only JSON Schema document. */
|
||||
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
|
||||
|
||||
/** Executable tool tool exposed through CodeMode's `tools` object. */
|
||||
/** Executable tool exposed through CodeMode's `tools` object. */
|
||||
export type Tool<R = never> = {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly description: string
|
||||
|
||||
@@ -592,7 +592,7 @@ describe("CodeMode public contract", () => {
|
||||
expect(second).toStrictEqual({ ok: true, value: 1, logs: ["hi"], toolCalls: [{ name: "host.echo" }] })
|
||||
})
|
||||
|
||||
test("describes the catalog and keeps the search built-in registered", async () => {
|
||||
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
@@ -601,7 +601,16 @@ describe("CodeMode public contract", () => {
|
||||
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
|
||||
},
|
||||
])
|
||||
expect(runtime.instructions()).toContain("Available tools (COMPLETE list")
|
||||
expect(runtime.instructions()).toContain("- orders (1 tool)")
|
||||
expect(runtime.instructions()).toContain(
|
||||
" - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID",
|
||||
)
|
||||
// A fully inlined catalog does not advertise search in the instructions...
|
||||
expect(runtime.instructions()).not.toContain("search(")
|
||||
|
||||
// ...but the search built-in stays available, so a speculative call still works with the
|
||||
// same signature as the inline catalog.
|
||||
const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
@@ -620,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",
|
||||
@@ -636,6 +680,9 @@ describe("CodeMode public contract", () => {
|
||||
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
|
||||
},
|
||||
])
|
||||
expect(runtime.instructions()).toContain(
|
||||
'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
|
||||
)
|
||||
|
||||
const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`))
|
||||
expect(search.ok).toBe(true)
|
||||
@@ -666,6 +713,88 @@ describe("CodeMode public contract", () => {
|
||||
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
|
||||
})
|
||||
|
||||
test("instructions use markdown sections with placeholder-only call forms", () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
const instructions = runtime.instructions()
|
||||
// Sections in order: workflow at the top, catalog at the bottom.
|
||||
expect(instructions).toContain("## Workflow")
|
||||
expect(instructions).toContain("## Rules")
|
||||
expect(instructions).toContain("## Language")
|
||||
expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules"))
|
||||
expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Language"))
|
||||
expect(instructions.indexOf("## Language")).toBeLessThan(
|
||||
instructions.indexOf("\n## Available tools (COMPLETE list"),
|
||||
)
|
||||
expect(instructions).not.toContain("JSON.parse(res)")
|
||||
expect(instructions).toContain("Return only the fields you need")
|
||||
expect(instructions).toContain("avoid returning large raw payloads")
|
||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain("bracket notation and quotes are part of the path")
|
||||
expect(instructions).toContain("surrounding agent tools are not available")
|
||||
expect(instructions).toContain("Only tools listed here are available")
|
||||
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
|
||||
// and no real catalog tools cherry-picked into example lines.
|
||||
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
|
||||
expect(instructions).toContain("Return only the fields you need from structured results")
|
||||
expect(instructions).toContain("check that it is a non-null object and not an array")
|
||||
expect(instructions).not.toContain("result.<field>")
|
||||
expect(instructions).not.toContain("data.<field>")
|
||||
expect(instructions).not.toContain("total_count")
|
||||
expect(instructions).not.toContain("list_issues")
|
||||
expect(instructions).not.toContain("tools.orders.lookup({")
|
||||
// COMPLETE: step 1 picks from the inlined list; search is not advertised.
|
||||
expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`")
|
||||
expect(instructions).not.toContain("Browse one namespace")
|
||||
|
||||
const partial = CodeMode.make({ tools, discovery: { catalogBudget: 0 } }).instructions()
|
||||
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
|
||||
// a query string, never a tool name) and the browse-namespace rule appears.
|
||||
expect(partial).toContain(
|
||||
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
|
||||
)
|
||||
expect(partial).toContain("In the next execution, copy a returned path exactly")
|
||||
expect(partial).toContain("Only tools listed here or returned by the built-in `search` function")
|
||||
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
|
||||
expect(partial).toContain("repeat the same search with `offset: next.offset`")
|
||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||
expect(partial).not.toContain("total_count")
|
||||
expect(partial).not.toContain("tools.orders.lookup({")
|
||||
})
|
||||
|
||||
test("the language section describes the restricted runtime without overclaiming", () => {
|
||||
const instructions = CodeMode.make({ tools }).instructions()
|
||||
expect(instructions).toContain("restricted JavaScript language for calling tools")
|
||||
expect(instructions).toContain("not a general-purpose runtime")
|
||||
expect(instructions).not.toContain("Standard modern JavaScript works")
|
||||
expect(instructions).not.toContain("TypeScript type annotations")
|
||||
for (const missing of ["Modules/imports", "classes", "fetch"]) {
|
||||
expect(instructions).toContain(missing)
|
||||
}
|
||||
expect(instructions).not.toContain("generators")
|
||||
expect(instructions).not.toContain("new Promise(...) are unavailable")
|
||||
expect(instructions).not.toContain("promise chaining")
|
||||
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
|
||||
expect(instructions).not.toContain("host globals")
|
||||
expect(instructions).toContain("Use tools for external operations")
|
||||
expect(instructions).toContain(
|
||||
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
|
||||
)
|
||||
expect(instructions).toContain(
|
||||
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
|
||||
)
|
||||
})
|
||||
|
||||
test("zero tools keep minimal sections and the no-tools notice", () => {
|
||||
const runtime = CodeMode.make({})
|
||||
const instructions = runtime.instructions()
|
||||
expect(instructions).toContain("No tools are currently available.")
|
||||
expect(instructions).toContain("## Language")
|
||||
expect(instructions).toContain("## Available tools")
|
||||
expect(instructions).not.toContain("## Workflow")
|
||||
expect(instructions).not.toContain("## Rules")
|
||||
expect(instructions).not.toContain("search(")
|
||||
})
|
||||
|
||||
test("uses one ranked search returning complete tools for large catalogs", async () => {
|
||||
const upload = Tool.make({
|
||||
description: "Upload one readable local file to the current Discord thread",
|
||||
@@ -681,7 +810,13 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
||||
discovery: { catalogBudget: 0 },
|
||||
})
|
||||
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with search(...))")
|
||||
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
|
||||
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
|
||||
expect(runtime.instructions()).toContain("Search returns complete callable signatures:\n- search(input: {")
|
||||
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
@@ -966,6 +1101,64 @@ describe("CodeMode public contract", () => {
|
||||
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
|
||||
})
|
||||
|
||||
test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => {
|
||||
const cheap = Tool.make({
|
||||
description: "Cheap",
|
||||
input: Schema.Struct({ q: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const expensive = Tool.make({
|
||||
description:
|
||||
"An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime",
|
||||
input: Schema.Struct({
|
||||
someRatherLongParameterName: Schema.String,
|
||||
anotherEvenLongerParameterName: Schema.Number,
|
||||
}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
// Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2
|
||||
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
|
||||
// other namespaces from inlining (beta already got its line in the same round).
|
||||
const runtime = CodeMode.make({
|
||||
tools: { alpha: { cheap, expensive }, beta: { cheap } },
|
||||
discovery: { catalogBudget: 40 },
|
||||
})
|
||||
|
||||
const instructions = runtime.instructions()
|
||||
expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with search(...))")
|
||||
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
|
||||
expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
||||
expect(instructions).not.toContain("tools.alpha.expensive(")
|
||||
// Fully shown namespaces read cleanly (no "shown" annotation).
|
||||
expect(instructions).toContain("- beta (1 tool)")
|
||||
expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
||||
expect(instructions).toContain("Search returns complete callable signatures:\n- search(input: {")
|
||||
})
|
||||
|
||||
test("charges inline JSDoc against the catalog token budget", () => {
|
||||
const documented = Tool.make({
|
||||
description: "Look up a record",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "A detailed identifier description. ".repeat(20) },
|
||||
},
|
||||
required: ["id"],
|
||||
} as const,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { records: { lookup: documented } },
|
||||
discovery: { catalogBudget: 40 },
|
||||
})
|
||||
|
||||
expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.")
|
||||
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))")
|
||||
expect(runtime.instructions()).not.toContain("tools.records.lookup(input:")
|
||||
})
|
||||
|
||||
test("decodes tool input and output before exposing either side", async () => {
|
||||
const observed: Array<unknown> = []
|
||||
const transformed = Tool.make({
|
||||
@@ -1026,7 +1219,7 @@ describe("CodeMode public contract", () => {
|
||||
expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] })
|
||||
})
|
||||
|
||||
test("rejects invalid configuration and search limits", async () => {
|
||||
test("rejects invalid configuration and discovery limits", async () => {
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError)
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(
|
||||
RangeError,
|
||||
@@ -1034,8 +1227,13 @@ describe("CodeMode public contract", () => {
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError)
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError)
|
||||
|
||||
expect(() => CodeMode.make({ tools, discovery: { catalogBudget: -1 } })).toThrow(RangeError)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({ tools }).execute(`return search({ query: "order", limit: 0.5 })`),
|
||||
CodeMode.make({
|
||||
tools,
|
||||
discovery: { catalogBudget: 0 },
|
||||
}).execute(`return search({ query: "order", limit: 0.5 })`),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
|
||||
@@ -395,15 +395,15 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("the catalog uses the same JSDoc signatures as search", async () => {
|
||||
const catalog = runtime.catalog()
|
||||
test("the inline catalog uses the same JSDoc signatures", async () => {
|
||||
const instructions = runtime.instructions()
|
||||
const github = (await search("list issues repository")).items.find(
|
||||
({ path }) => path === "tools.github.list_issues",
|
||||
)!
|
||||
const orders = (await search("look up order")).items.find(({ path }) => path === "tools.orders.lookup")!
|
||||
expect(catalog.map(({ signature }) => signature)).toContain(github.signature)
|
||||
expect(catalog.map(({ signature }) => signature)).toContain(orders.signature)
|
||||
expect(github.signature).toContain("/** Repository owner */")
|
||||
expect(instructions).toContain(` - ${github.signature} // List issues in a repository`)
|
||||
expect(instructions).toContain(` - ${orders.signature} // Look up an order`)
|
||||
expect(instructions).toContain("/** Repository owner */")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -423,10 +423,16 @@ describe("non-identifier tool paths", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
test("catalog signatures use bracket notation for dashed tool names", () => {
|
||||
expect(runtime.catalog()[0]?.signature).toBe(
|
||||
test("inline catalog uses bracket notation for dashed tool names", () => {
|
||||
const instructions = runtime.instructions()
|
||||
|
||||
expect(instructions).toContain(
|
||||
'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
|
||||
)
|
||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain("bracket notation and quotes are part of the path")
|
||||
expect(instructions).not.toContain("tools.context7.resolve-library-id")
|
||||
expect(instructions).not.toContain("tools.context7.resolve_library_id")
|
||||
})
|
||||
|
||||
test("search results return callable bracket-notation paths and signatures", async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("dotted tool names", () => {
|
||||
expect(catalog).toHaveLength(1)
|
||||
expect(catalog[0]?.path).toBe("api.issues.list")
|
||||
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(input:")
|
||||
expect(runtime.instructions()).toContain("tools.api.issues.list(input:")
|
||||
})
|
||||
|
||||
test("the advertised dotted path is executable", async () => {
|
||||
@@ -85,9 +86,6 @@ describe("callable namespaces", () => {
|
||||
const diagnostic = await failure(runtime, `return await tools.issues.missing({})`)
|
||||
expect(diagnostic.kind).toBe("UnknownTool")
|
||||
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
|
||||
expect(diagnostic.suggestions).toEqual([
|
||||
"The tool may have been removed or renamed. Use search to find available tools.",
|
||||
])
|
||||
})
|
||||
|
||||
test("a namespace without its own tool stays non-callable", async () => {
|
||||
@@ -98,31 +96,6 @@ describe("callable namespaces", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool input diagnostics", () => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
"notes.echo": Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ text }) => Effect.succeed(text),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
test("a schema mismatch suggests searching for the current signature", async () => {
|
||||
const diagnostic = await failure(runtime, `return await tools.notes.echo({ message: "hello" })`)
|
||||
expect(diagnostic.kind).toBe("InvalidToolInput")
|
||||
expect(diagnostic.suggestions).toEqual(["The signature may have changed. Use search to get the current signature."])
|
||||
})
|
||||
|
||||
test("a wrong argument count keeps the existing error without a stale-signature hint", async () => {
|
||||
const diagnostic = await failure(runtime, `return await tools.notes.echo()`)
|
||||
expect(diagnostic.kind).toBe("InvalidToolInput")
|
||||
expect(diagnostic.suggestions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("blocked member names on tool paths", () => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
@@ -133,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")
|
||||
@@ -182,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,7 +1,6 @@
|
||||
export * as CodeMode from "./codemode"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { CodeModeCatalog } from "./codemode/catalog"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { ExecuteTool } from "./tool/execute"
|
||||
@@ -10,7 +9,7 @@ import { Wildcard } from "./util/wildcard"
|
||||
|
||||
export interface Materialization {
|
||||
readonly tool?: Any
|
||||
readonly catalog?: ReadonlyArray<CodeModeCatalog.Entry>
|
||||
readonly instructions?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -68,7 +67,7 @@ const layer = Layer.effect(
|
||||
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
|
||||
return {
|
||||
tool: ExecuteTool.create(registrations),
|
||||
catalog: ExecuteTool.catalog(registrations),
|
||||
instructions: ExecuteTool.instructions(registrations),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
export * as CodeModeCatalog from "./catalog"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Entry = Schema.Struct({
|
||||
path: Schema.String,
|
||||
description: Schema.String,
|
||||
signature: Schema.String,
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
|
||||
const Listing = Schema.Struct({
|
||||
path: Schema.String,
|
||||
line: Schema.String,
|
||||
})
|
||||
|
||||
const Namespace = Schema.Struct({
|
||||
name: Schema.String,
|
||||
count: Schema.Number,
|
||||
entries: Schema.Array(Listing),
|
||||
})
|
||||
|
||||
export const Summary = Schema.Struct({
|
||||
total: Schema.Number,
|
||||
shown: Schema.Number,
|
||||
namespaces: Schema.Array(Namespace),
|
||||
})
|
||||
export type Summary = typeof Summary.Type
|
||||
|
||||
const DESCRIPTION_LIMIT = 120
|
||||
const CHARACTERS_PER_TOKEN = 4
|
||||
const INLINE_BUDGET = 2_000
|
||||
|
||||
// Keep every namespace searchable, then select full listings one per namespace per round,
|
||||
// considering shorter listings first until the inline budget is exhausted.
|
||||
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
|
||||
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
|
||||
.sort(([left], [right]) => {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
})
|
||||
.map(([name, namespaceEntries]) => {
|
||||
const listings = namespaceEntries
|
||||
.map((entry) => {
|
||||
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
|
||||
const description =
|
||||
firstLine.length > DESCRIPTION_LIMIT
|
||||
? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..."
|
||||
: firstLine
|
||||
const suffix = description.length === 0 ? "" : ` // ${description}`
|
||||
return { path: entry.path, line: ` - ${entry.signature}${suffix}` }
|
||||
})
|
||||
.toSorted((left, right) => {
|
||||
if (left.path < right.path) return -1
|
||||
if (left.path > right.path) return 1
|
||||
return 0
|
||||
})
|
||||
return {
|
||||
name,
|
||||
listings,
|
||||
selectionOrder: rankListings(listings),
|
||||
selectedListings: new Set<typeof Listing.Type>(),
|
||||
}
|
||||
})
|
||||
|
||||
const active = new Set(namespaces)
|
||||
let remaining = budget
|
||||
while (active.size > 0) {
|
||||
for (const namespace of active) {
|
||||
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
|
||||
if (!candidate || candidate.cost > remaining) {
|
||||
active.delete(namespace)
|
||||
continue
|
||||
}
|
||||
namespace.selectedListings.add(candidate.listing)
|
||||
remaining -= candidate.cost
|
||||
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
|
||||
}
|
||||
}
|
||||
|
||||
const namespaceSummaries = namespaces.map((namespace) => ({
|
||||
name: namespace.name,
|
||||
count: namespace.listings.length,
|
||||
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
|
||||
}))
|
||||
return {
|
||||
total: entries.length,
|
||||
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
|
||||
namespaces: namespaceSummaries,
|
||||
}
|
||||
}
|
||||
|
||||
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
|
||||
return listings
|
||||
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
|
||||
.toSorted((left, right) => {
|
||||
if (left.cost !== right.cost) return left.cost - right.cost
|
||||
if (left.listing.path < right.listing.path) return -1
|
||||
if (left.listing.path > right.listing.path) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
@@ -1,159 +1,24 @@
|
||||
export * as CodeModeInstructions from "./instructions"
|
||||
|
||||
import { searchSignature, toolExpression } from "@opencode-ai/codemode"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { CodeMode } from "../codemode"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { CodeModeCatalog } from "./catalog"
|
||||
|
||||
// prettier-ignore
|
||||
const prompt = (hasMoreTools: boolean) => `Run JavaScript to orchestrate tool calls and compose their results. Imports, filesystem access, and timers are unavailable. Do not use \`fetch\`; all API calls go through \`tools\`.
|
||||
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.",
|
||||
}
|
||||
|
||||
Prefer an explicit \`return\`; if omitted, the final top-level expression becomes the result. Await tool calls before returning; any calls still pending when execution ends are interrupted. Run independent calls concurrently with \`Promise.all\`.
|
||||
|
||||
Do not infer or normalize tool names; use only the exact signatures shown below${hasMoreTools ? " or returned by `search`" : ""}, preserving bracket notation such as \`tools.<namespace>["tool-name"](input)\`.${hasMoreTools ? `
|
||||
|
||||
## Search
|
||||
|
||||
Only some tool signatures are shown. Use \`search\` to discover exact paths and signatures for additional tools:
|
||||
|
||||
- ${searchSignature}` : ""}
|
||||
|
||||
## Available tools`
|
||||
|
||||
export function render(catalog: CodeModeCatalog.Summary) {
|
||||
if (catalog.total === 0) return "No tools are currently available."
|
||||
|
||||
const tools = catalog.namespaces.flatMap((namespace) => {
|
||||
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
|
||||
const label =
|
||||
namespace.entries.length === namespace.count
|
||||
? count
|
||||
: namespace.entries.length === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${namespace.entries.length} shown`
|
||||
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
|
||||
export const make = (content?: string): Instructions.Instructions =>
|
||||
Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
read: Effect.succeed(content ?? Instructions.removed),
|
||||
render,
|
||||
})
|
||||
|
||||
return `${prompt(catalog.shown < catalog.total)}
|
||||
|
||||
${tools.join("\n")}`
|
||||
}
|
||||
|
||||
export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) {
|
||||
const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
|
||||
|
||||
${render(current)}`
|
||||
const previousComplete = previous.shown === previous.total
|
||||
const currentComplete = current.shown === current.total
|
||||
if (previousComplete !== currentComplete) return full
|
||||
|
||||
const diff = Instructions.diffByKey(
|
||||
previous.namespaces.flatMap((namespace) => namespace.entries),
|
||||
current.namespaces.flatMap((namespace) => namespace.entries),
|
||||
(entry) => entry.path,
|
||||
(before, after) => before.line !== after.line,
|
||||
)
|
||||
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0
|
||||
|
||||
if (!currentComplete) {
|
||||
if (entriesChanged) return full
|
||||
const namespaces = Instructions.diffByKey(
|
||||
previous.namespaces,
|
||||
current.namespaces,
|
||||
(namespace) => namespace.name,
|
||||
(before, after) => before.count !== after.count,
|
||||
)
|
||||
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
|
||||
if (!changed) return full
|
||||
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (namespaces.added.length > 0) {
|
||||
parts.push(
|
||||
`New tool namespaces are available: ${namespaces.added
|
||||
.map((namespace) => `\`${namespace.name}\` (${namespace.count} tools)`)
|
||||
.join(", ")}.`,
|
||||
)
|
||||
}
|
||||
if (namespaces.changed.length > 0) {
|
||||
parts.push(
|
||||
`The following namespace inventories changed; search them again before relying on previous results: ${namespaces.changed
|
||||
.map((change) => `\`${change.current.name}\` now has ${change.current.count} tools`)
|
||||
.join(", ")}.`,
|
||||
)
|
||||
}
|
||||
if (namespaces.removed.length > 0) {
|
||||
parts.push(
|
||||
`The following tool namespaces are no longer available and must not be used: ${namespaces.removed
|
||||
.map((namespace) => `\`${namespace.name}\``)
|
||||
.join(", ")}.`,
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < full.length) return delta
|
||||
return full
|
||||
}
|
||||
|
||||
if (!entriesChanged) return full
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (diff.added.length > 0) {
|
||||
parts.push(
|
||||
[
|
||||
"New tools are available in addition to those previously listed:",
|
||||
...diff.added.map((entry) => entry.line),
|
||||
].join("\n"),
|
||||
)
|
||||
}
|
||||
if (diff.changed.length > 0) {
|
||||
parts.push(
|
||||
[
|
||||
"Changed tool listings supersede the previously listed ones:",
|
||||
...diff.changed.map((change) => change.current.line),
|
||||
].join("\n"),
|
||||
)
|
||||
}
|
||||
if (diff.removed.length > 0) {
|
||||
parts.push(
|
||||
`The following tools are no longer available and must not be called: ${diff.removed
|
||||
.map((entry) => toolExpression(entry.path))
|
||||
.join(", ")}.`,
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < full.length) return delta
|
||||
return full
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
|
||||
}
|
||||
|
||||
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 entries = selection.info ? ((yield* codeMode.materialize(selection.info.permissions)).catalog ?? []) : []
|
||||
const catalog = CodeModeCatalog.summarize(entries)
|
||||
return Instructions.make<CodeModeCatalog.Summary>({
|
||||
key: Instructions.Key.make("core/codemode"),
|
||||
codec: Schema.toCodecJson(CodeModeCatalog.Summary),
|
||||
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: update,
|
||||
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] })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -394,19 +394,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
})
|
||||
}
|
||||
return toolHooks.hook.after((event) => {
|
||||
// JS plugin boundary: marshal the canonical outcome out, copy mutations back.
|
||||
const output: Record<string, unknown> = {
|
||||
// Decode first so plugin mutations cannot alias the canonical outcome.
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
messageID: event.messageID,
|
||||
callID: event.callID,
|
||||
input: event.input,
|
||||
status: event.status,
|
||||
content: event.content,
|
||||
metadata: event.metadata,
|
||||
outputPaths: event.outputPaths,
|
||||
...(event.status === "error" ? { error: event.error } : {}),
|
||||
...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event),
|
||||
}
|
||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||
Effect.tap(() => {
|
||||
@@ -417,16 +413,16 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
|
||||
return Effect.sync(() => {
|
||||
if (event.status === "completed" && decoded.value.status === "completed") {
|
||||
if (output.content !== event.content) event.content = decoded.value.content
|
||||
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
|
||||
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
return
|
||||
}
|
||||
if (event.status === "error" && decoded.value.status === "error") {
|
||||
if (output.error !== event.error) event.error = decoded.value.error
|
||||
if (output.content !== event.content) event.content = decoded.value.content
|
||||
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
|
||||
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
|
||||
event.error = decoded.value.error
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -130,7 +130,7 @@ const layer = Layer.effect(
|
||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Effect } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { SessionEvent } from "../event"
|
||||
@@ -12,7 +12,6 @@ import { Snapshot } from "../../snapshot"
|
||||
import { RelativePath } from "../../schema"
|
||||
import { SessionUsage } from "../usage"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { MAX_BYTES } from "../../tool-output-store"
|
||||
import type { ToolRegistry } from "../../tool/registry"
|
||||
|
||||
type Input = {
|
||||
@@ -28,9 +27,11 @@ const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
||||
|
||||
/** Derives canonical model content from a provider-hosted tool result. */
|
||||
const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => {
|
||||
if (result.type === "content" && result.value.length > 0)
|
||||
return result.value as unknown as readonly [ToolContent, ...ToolContent[]]
|
||||
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
|
||||
if (result.type === "content") {
|
||||
const content = Tool.nonEmpty(result.value)
|
||||
if (content !== undefined) return content
|
||||
}
|
||||
return [{ type: "text", text: Tool.stringify(result.value) }]
|
||||
}
|
||||
|
||||
@@ -47,11 +48,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
progress?: ToolRegistry.Progress
|
||||
}
|
||||
>()
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
|
||||
if (!tool.progress) return {}
|
||||
const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES)
|
||||
return metadata === undefined ? {} : { metadata }
|
||||
}
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
|
||||
tool.progress === undefined ? {} : { metadata: tool.progress }
|
||||
let assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
@@ -60,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
|
||||
@@ -292,7 +290,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
@@ -405,12 +403,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
tool.settled = true
|
||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||
const resultState = providerState(event.providerMetadata)
|
||||
if (error !== undefined || event.result.type === "error") {
|
||||
if (event.result.type === "error") {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: error ?? { type: "tool.execution", message: Tool.stringify(event.result.value) },
|
||||
error: { type: "tool.execution", message: Tool.stringify(event.result.value) },
|
||||
...failureSnapshot(tool),
|
||||
executed,
|
||||
resultState,
|
||||
@@ -451,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
|
||||
@@ -471,13 +469,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
const tool = tools.get(callID)
|
||||
if (!tool?.called || tool.settled)
|
||||
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
|
||||
const current = { ...update }
|
||||
tool.progress = current
|
||||
tool.progress = update
|
||||
yield* events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
metadata: current,
|
||||
metadata: update,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -126,8 +126,8 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
|
||||
export const instructions = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).instructions()
|
||||
}
|
||||
|
||||
function runtime(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -22,13 +22,8 @@ describe("CodeMode", () => {
|
||||
|
||||
const materialized = yield* codeMode.materialize()
|
||||
expect(materialized.tool).toBeDefined()
|
||||
expect(materialized.catalog).toStrictEqual([
|
||||
{
|
||||
path: "echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
},
|
||||
])
|
||||
expect(materialized.instructions).toContain("Echo text")
|
||||
expect(materialized.instructions).toContain("tools.echo(input:")
|
||||
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
|
||||
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
|
||||
path,
|
||||
description,
|
||||
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
|
||||
})
|
||||
|
||||
const lookup = entry(
|
||||
"orders.lookup",
|
||||
"Look up an order by ID",
|
||||
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
|
||||
)
|
||||
|
||||
const render = (entries: ReadonlyArray<CodeModeCatalog.Entry>, budget?: number) =>
|
||||
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
|
||||
|
||||
const update = (
|
||||
previous: ReadonlyArray<CodeModeCatalog.Entry>,
|
||||
current: ReadonlyArray<CodeModeCatalog.Entry>,
|
||||
budget?: number,
|
||||
) =>
|
||||
CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget))
|
||||
|
||||
describe("CodeModeCatalog.summarize", () => {
|
||||
test("retains namespace inventory without retaining tools outside the inline budget", () => {
|
||||
const catalog = CodeModeCatalog.summarize(
|
||||
Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)),
|
||||
0,
|
||||
)
|
||||
expect(catalog).toEqual({
|
||||
total: 10_000,
|
||||
shown: 0,
|
||||
namespaces: [{ name: "bulk", count: 10_000, entries: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
test("retains every namespace when no full tool listing fits", () => {
|
||||
const catalog = CodeModeCatalog.summarize(
|
||||
[entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")],
|
||||
0,
|
||||
)
|
||||
expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"])
|
||||
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
test("retains only the rendered portion of inline descriptions", () => {
|
||||
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
|
||||
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
|
||||
})
|
||||
|
||||
test("limits inline descriptions to 120 characters", () => {
|
||||
const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))])
|
||||
const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1]
|
||||
expect(description).toHaveLength(120)
|
||||
expect(description).toEndWith("...")
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeModeInstructions.render", () => {
|
||||
test("inlines complete catalogs without search guidance", () => {
|
||||
const instructions = render([lookup])
|
||||
expect(instructions).toContain("## Available tools")
|
||||
expect(instructions).toContain("- orders (1 tool)")
|
||||
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
|
||||
expect(instructions).not.toContain("## Search")
|
||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain('`tools.<namespace>["tool-name"](input)`')
|
||||
})
|
||||
|
||||
test("describes the runtime and execution lifecycle concisely", () => {
|
||||
const instructions = render([lookup])
|
||||
expect(instructions).toContain("Run JavaScript to orchestrate tool calls and compose their results.")
|
||||
expect(instructions).toContain("Imports, filesystem access, and timers are unavailable.")
|
||||
expect(instructions).toContain("Do not use `fetch`; all API calls go through `tools`.")
|
||||
expect(instructions).toContain(
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
)
|
||||
expect(instructions).toContain("any calls still pending when execution ends are interrupted")
|
||||
expect(instructions).toContain("Run independent calls concurrently with `Promise.all`.")
|
||||
})
|
||||
|
||||
test("adds search guidance when the catalog exceeds the budget", () => {
|
||||
const partial = render([lookup], 0)
|
||||
expect(partial).toContain("## Available tools")
|
||||
expect(partial).toContain("- orders (1 tool, none shown)")
|
||||
expect(partial).toContain("## Search")
|
||||
expect(partial).toContain("Only some tool signatures are shown.")
|
||||
expect(partial).toContain("- search(input: {")
|
||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||
expect(partial).toContain("or returned by `search`")
|
||||
expect(partial).not.toContain("tools.orders.lookup(input:")
|
||||
})
|
||||
|
||||
test("budgets signatures round-robin so every namespace remains visible", () => {
|
||||
const cheapAlpha = entry("alpha.cheap", "Cheap")
|
||||
const cheapBeta = entry("beta.cheap", "Cheap")
|
||||
const expensive = entry(
|
||||
"alpha.expensive",
|
||||
"Expensive",
|
||||
`tools.alpha.expensive(input: {\n aVeryLongParameterName: string,\n anotherEvenLongerParameterName: number,\n yetAnotherExtremelyVerboseParameterName: string,\n}): Promise<string>`,
|
||||
)
|
||||
// Round 1 places alpha.cheap and beta.cheap; in round 2 alpha.expensive does not fit,
|
||||
// which marks only alpha done - it must NOT prevent other namespaces from inlining.
|
||||
const instructions = render([cheapAlpha, expensive, cheapBeta], 40)
|
||||
expect(instructions).toContain("## Search")
|
||||
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
|
||||
expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`)
|
||||
expect(instructions).not.toContain("tools.alpha.expensive(")
|
||||
expect(instructions).toContain("- beta (1 tool)")
|
||||
expect(instructions).toContain(` - ${cheapBeta.signature} // Cheap`)
|
||||
})
|
||||
|
||||
test("charges inline JSDoc in signatures against the catalog token budget", () => {
|
||||
const documented = entry(
|
||||
"records.lookup",
|
||||
"Look up a record",
|
||||
`tools.records.lookup(input: {\n /** ${"A detailed identifier description. ".repeat(20).trim()} */\n id: string,\n}): Promise<string>`,
|
||||
)
|
||||
const instructions = render([documented], 40)
|
||||
expect(instructions).toContain("- records (1 tool, none shown)")
|
||||
expect(instructions).not.toContain("tools.records.lookup(input:")
|
||||
})
|
||||
|
||||
test("renders only the no-tools notice for an empty catalog", () => {
|
||||
expect(render([])).toBe("No tools are currently available.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeModeInstructions.update", () => {
|
||||
const echo = entry("notes.echo", "Echo text")
|
||||
|
||||
test("renders additions, changes, and removals as a compact semantic delta", () => {
|
||||
const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>" }
|
||||
const added = entry("notes.list", "List notes")
|
||||
const text = update([echo, lookup], [changed, added])
|
||||
expect(text).toContain("The Code Mode tool catalog has changed.")
|
||||
expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`)
|
||||
expect(text).toContain(
|
||||
`Changed tool listings supersede the previously listed ones:\n - ${changed.signature} // Echo text`,
|
||||
)
|
||||
expect(text).toContain("The following tools are no longer available and must not be called: tools.orders.lookup.")
|
||||
expect(text).not.toContain("## Available tools")
|
||||
})
|
||||
|
||||
test("names removed tools with exact callable expressions including bracket notation", () => {
|
||||
const dashed = entry("context7.resolve-library-id", "Resolve a library ID")
|
||||
const text = update([echo, dashed], [echo])
|
||||
expect(text).toContain(
|
||||
'The following tools are no longer available and must not be called: tools.context7["resolve-library-id"].',
|
||||
)
|
||||
})
|
||||
|
||||
test("restates the full catalog when the rendering mode crosses full and compact", () => {
|
||||
const wide = Array.from({ length: 40 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
|
||||
const text = update([echo], [echo, ...wide], 30)
|
||||
expect(text).toContain(
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
|
||||
)
|
||||
expect(text).toContain("## Search")
|
||||
expect(text).toContain("## Available tools")
|
||||
})
|
||||
|
||||
test("falls back to full replacement when the delta is larger than the catalog", () => {
|
||||
const previous = Array.from({ length: 200 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
|
||||
const text = update([...previous, echo], [echo])
|
||||
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
|
||||
expect(text).toContain("## Available tools")
|
||||
expect(text).not.toContain("## Search")
|
||||
expect(text).not.toContain("must not be called")
|
||||
})
|
||||
|
||||
test("renders namespace-only deltas without persisting hidden tool entries", () => {
|
||||
const alpha = Array.from({ length: 10 }, (_, index) => entry(`alpha.tool${index}`, `Tool ${index}`))
|
||||
const text = update(alpha, [...alpha, entry("alpha.tool10", "Tool 10")], 0)
|
||||
expect(text).toContain("`alpha` now has 11 tools")
|
||||
expect(text).toContain("search them again before relying on previous results")
|
||||
expect(text).not.toContain("tools.alpha.tool10(input:")
|
||||
expect(text).not.toContain("## Available tools")
|
||||
})
|
||||
})
|
||||
@@ -1,97 +1,66 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { CodeMode } from "@opencode-ai/core/codemode"
|
||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
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")))
|
||||
|
||||
const echo = {
|
||||
path: "notes.echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
}
|
||||
|
||||
const lookup = {
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order",
|
||||
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
|
||||
}
|
||||
|
||||
describe("CodeModeInstructions", () => {
|
||||
it.effect("renders the initial catalog, semantic deltas, and removal", () => {
|
||||
let catalog: ReadonlyArray<CodeModeCatalog.Entry> | undefined = [echo]
|
||||
const layer = AppNodeBuilder.build(CodeModeInstructions.node, [
|
||||
[
|
||||
CodeMode.node,
|
||||
Layer.mock(CodeMode.Service, {
|
||||
materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { 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))
|
||||
expect(initialized.text).toContain("## Available tools")
|
||||
expect(initialized.text).not.toContain("## Search")
|
||||
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
|
||||
|
||||
catalog = [echo, lookup]
|
||||
const added = yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
|
||||
expect(added.text).toContain("The Code Mode tool catalog has changed.")
|
||||
expect(added.text).toContain("New tools are available in addition to those previously listed:")
|
||||
expect(added.text).toContain(` - ${lookup.signature} // Look up an order`)
|
||||
expect(added.text).not.toContain("## Available tools")
|
||||
|
||||
catalog = [echo]
|
||||
const removed = yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, { values: added.values })))
|
||||
expect(removed.text).toBe(
|
||||
"The Code Mode tool catalog has changed.\n\n" +
|
||||
"The following tools are no longer available and must not be called: tools.orders.lookup.",
|
||||
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)
|
||||
}),
|
||||
)
|
||||
|
||||
catalog = undefined
|
||||
expect(
|
||||
yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({
|
||||
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(reordered.changed).toBe(false)
|
||||
expect(reordered.text).toBe("")
|
||||
}).pipe(Effect.provide(codeModeLayer))
|
||||
})
|
||||
|
||||
it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => {
|
||||
let catalog: ReadonlyArray<CodeModeCatalog.Entry> = [lookup, echo]
|
||||
const layer = AppNodeBuilder.build(CodeModeInstructions.node, [
|
||||
[
|
||||
CodeMode.node,
|
||||
Layer.mock(CodeMode.Service, {
|
||||
materialize: () => Effect.succeed({ catalog }),
|
||||
register: () => Effect.void,
|
||||
}),
|
||||
],
|
||||
])
|
||||
it.effect("renders catalog changes and removal", () => {
|
||||
let catalog: string | undefined = "Initial Code Mode catalog"
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* CodeModeInstructions.Service
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make(catalog))
|
||||
expect(initialized.text).toBe("Initial Code Mode catalog")
|
||||
|
||||
catalog = [echo, lookup]
|
||||
const update = yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
|
||||
expect(update.changed).toBe(false)
|
||||
}).pipe(Effect.provide(layer))
|
||||
catalog = "Updated Code Mode catalog"
|
||||
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* readUpdate(CodeModeInstructions.make(catalog), initialized)).toMatchObject({
|
||||
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
}),
|
||||
@@ -365,7 +365,7 @@ describe("PluginV2", () => {
|
||||
yield* ctx.tool
|
||||
.hook("execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.status === "completed") event.content = [] as never
|
||||
if (event.status === "completed") (event.content as unknown as unknown[]).splice(0)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.asVoid)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -118,9 +118,7 @@ test("provider-executed success derives content and retains provider result stat
|
||||
test("interrupted progress metadata remains in the terminal failure snapshot", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
const exit = await Effect.runPromiseExit(
|
||||
publisher.progress(call.id, { phase: "visible" }),
|
||||
)
|
||||
const exit = await Effect.runPromiseExit(publisher.progress(call.id, { phase: "visible" }))
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
|
||||
|
||||
@@ -129,6 +127,18 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
|
||||
})
|
||||
})
|
||||
|
||||
test("failure snapshot retains canonical progress above the default byte limit", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
const detail = "x".repeat(60 * 1024)
|
||||
await Effect.runPromiseExit(publisher.progress(call.id, { detail }))
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
metadata: { detail },
|
||||
})
|
||||
})
|
||||
|
||||
test("failure before progress omits partial output fields", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
@@ -245,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" })
|
||||
@@ -258,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,
|
||||
@@ -301,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()),
|
||||
|
||||
@@ -767,19 +767,23 @@ export function RunSubagentSelectBody(props: {
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const [active, setActive] = createSignal(true)
|
||||
const entries = createMemo<SubagentEntry[]>(() =>
|
||||
props.tabs().map((item) => {
|
||||
const title = item.description || item.title || item.label
|
||||
return {
|
||||
category: "",
|
||||
display: title,
|
||||
description: title === item.label ? undefined : item.label,
|
||||
footer: subagentStatusLabel(item.status),
|
||||
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
|
||||
sessionID: item.sessionID,
|
||||
current: props.current() === item.sessionID,
|
||||
}
|
||||
}),
|
||||
props
|
||||
.tabs()
|
||||
.filter((item) => (active() ? item.status === "running" : item.status !== "running"))
|
||||
.map((item) => {
|
||||
const title = item.description || item.title || item.label
|
||||
return {
|
||||
category: "",
|
||||
display: title,
|
||||
description: title === item.label ? undefined : item.label,
|
||||
footer: subagentStatusLabel(item.status),
|
||||
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
|
||||
sessionID: item.sessionID,
|
||||
current: props.current() === item.sessionID,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
@@ -788,6 +792,12 @@ export function RunSubagentSelectBody(props: {
|
||||
onSelect: (item) => props.onSelect(item.sessionID),
|
||||
isCurrent: (item) => item.current,
|
||||
closeOnFirstUp: true,
|
||||
onKey(event) {
|
||||
if (event.name.toLowerCase() !== "tab") return false
|
||||
event.preventDefault()
|
||||
setActive((value) => !value)
|
||||
return true
|
||||
},
|
||||
onRows: props.onRows,
|
||||
})
|
||||
|
||||
@@ -801,6 +811,7 @@ export function RunSubagentSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
hint={`tab show ${active() ? "inactive" : "active"}`}
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
|
||||
@@ -740,7 +740,7 @@ test("direct command panel keeps completed subagents available", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("direct subagent panel renders active subagents", async () => {
|
||||
test("direct subagent panel toggles between active and inactive subagents", async () => {
|
||||
const [tabs] = createSignal([
|
||||
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
|
||||
subagent({ sessionID: "s-2", label: "General", description: "Write migration plan", status: "completed" }),
|
||||
@@ -776,12 +776,22 @@ test("direct subagent panel renders active subagents", async () => {
|
||||
|
||||
expect(frame).toContain("Select subagent")
|
||||
expect(frame).toContain("Inspect auth flow")
|
||||
expect(frame).toContain("Write migration plan")
|
||||
expect(frame).toContain("done")
|
||||
expect(frame).not.toContain("Write migration plan")
|
||||
expect(frame).not.toContain("done")
|
||||
expect(frame).toContain("tab show inactive")
|
||||
expect(frame).not.toContain("┌")
|
||||
expect(frame).not.toContain("┃")
|
||||
expectPaletteList(list, 0)
|
||||
expect(rows).toBe(8)
|
||||
expect(rows).toBe(7)
|
||||
|
||||
app.mockInput.pressKey("TAB")
|
||||
await app.renderOnce()
|
||||
const inactive = app.captureCharFrame()
|
||||
|
||||
expect(inactive).not.toContain("Inspect auth flow")
|
||||
expect(inactive).toContain("Write migration plan")
|
||||
expect(inactive).toContain("done")
|
||||
expect(inactive).toContain("tab show active")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -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