mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-25 04:06:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34ed5bb399 | ||
|
|
e48306e7e1 |
@@ -10,7 +10,7 @@
|
||||
|
||||
## Conventions
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generateTurn`, `LLM.streamTurn`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -223,7 +223,7 @@ Routes lower these into provider-native assistant tool-call messages and tool-re
|
||||
|
||||
### Tool dispatch
|
||||
|
||||
`LLM.streamTurn(request)` and `LLM.generateTurn(request)` each run exactly one provider turn. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`.
|
||||
`LLM.stream(request)` and `LLM.generate(request)` each run exactly one provider turn. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`.
|
||||
|
||||
```ts
|
||||
const get_weather = tool({
|
||||
@@ -240,7 +240,7 @@ const get_weather = tool({
|
||||
})
|
||||
|
||||
const tools = { get_weather, get_time, ... }
|
||||
const events = yield* LLM.streamTurn(
|
||||
const events = yield* LLM.stream(
|
||||
LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }),
|
||||
).pipe(Stream.runCollect)
|
||||
|
||||
|
||||
@@ -175,8 +175,8 @@ const request = LLM.request({
|
||||
prompt: "Say hello.",
|
||||
})
|
||||
|
||||
// Current API: this performs one provider turn.
|
||||
const response = yield * LLM.generateTurn(request)
|
||||
// Current API: this performs one provider turn, despite the broad name.
|
||||
const response = yield * LLM.generate(request)
|
||||
|
||||
// Current API: execution also needs LLMClient.layer and RequestExecutor services.
|
||||
```
|
||||
@@ -432,7 +432,7 @@ const request = LLM.request({
|
||||
tools: Tool.toDefinitions(tools),
|
||||
})
|
||||
|
||||
const events = yield * LLM.streamTurn(request).pipe(Stream.runCollect)
|
||||
const events = yield * LLM.stream(request).pipe(Stream.runCollect)
|
||||
const call = Array.from(events).find(LLMEvent.is.toolCall)
|
||||
|
||||
if (call && !call.providerExecuted) {
|
||||
@@ -1076,7 +1076,7 @@ The redesign intentionally removes or changes these current concepts:
|
||||
| Current | Proposed |
|
||||
| --------------------------------------- | ----------------------------------------------------------- |
|
||||
| Mandatory `LLM.request({ model, ... })` | Inline calls or model-free portable requests |
|
||||
| No complete-run API | Add `LLM.generate` / `LLM.stream` |
|
||||
| `LLM.generate` means one turn | `LLM.generate` means complete run |
|
||||
| `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn |
|
||||
| `LLMClient.layer` requirement | Standard Effect requirements exposed directly |
|
||||
| Public `Route` mental model | Hidden behind executable `Model` |
|
||||
|
||||
@@ -176,7 +176,7 @@ Conversational image generation remains part of the LLM interaction. OpenAI Resp
|
||||
|
||||
```ts
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLM.generateTurn(
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
|
||||
prompt: "Design a solarpunk rooftop garden, then show me.",
|
||||
@@ -193,7 +193,7 @@ The hosted result is represented as a provider-executed tool call and tool resul
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
- **`LLM.generateTurn` / `LLM.streamTurn`** — execute exactly one provider turn, re-exported from `LLMClient` for one-import use.
|
||||
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
||||
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
|
||||
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
||||
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
||||
|
||||
@@ -334,7 +334,7 @@ Final request call site stays boring:
|
||||
```ts
|
||||
const response =
|
||||
yield *
|
||||
LLM.generateTurn(
|
||||
LLM.generate(
|
||||
LLM.request({
|
||||
model: DeepSeek.model("deepseek-chat"),
|
||||
prompt: "Hello.",
|
||||
|
||||
@@ -65,7 +65,7 @@ const rawOverlayExample = LLM.request({
|
||||
// 3. `generate` sends the request and collects the event stream into one
|
||||
// response object. `response.text` is the collected text output.
|
||||
const generateOnce = Effect.gen(function* () {
|
||||
const response = yield* LLM.generateTurn(request)
|
||||
const response = yield* LLM.generate(request)
|
||||
|
||||
console.log("\n== generate ==")
|
||||
console.log("generated text:", response.text)
|
||||
@@ -74,7 +74,7 @@ const generateOnce = Effect.gen(function* () {
|
||||
|
||||
// 4. `stream` exposes provider output as common `LLMEvent`s for UIs that want
|
||||
// incremental text, reasoning, tool input, usage, or finish events.
|
||||
const streamText = LLM.streamTurn(request).pipe(
|
||||
const streamText = LLM.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
|
||||
@@ -106,7 +106,7 @@ const streamWithTools = Effect.gen(function* () {
|
||||
generation: { maxTokens: 80, temperature: 0 },
|
||||
tools: Tool.toDefinitions(tools),
|
||||
})
|
||||
const events = Array.from(yield* LLM.streamTurn(request).pipe(Stream.runCollect))
|
||||
const events = Array.from(yield* LLM.stream(request).pipe(Stream.runCollect))
|
||||
for (const event of events) {
|
||||
if (event.type === "tool-call") console.log("tool call", event.name, event.input)
|
||||
if (event.type === "text-delta") process.stdout.write(event.text)
|
||||
|
||||
@@ -31,9 +31,9 @@ export type RequestInput = Omit<
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
export const generateTurn = LLMClient.generate
|
||||
export const generate = LLMClient.generate
|
||||
|
||||
export const streamTurn = LLMClient.stream
|
||||
export const stream = LLMClient.stream
|
||||
|
||||
export const request = (input: RequestInput) => {
|
||||
const {
|
||||
|
||||
@@ -412,7 +412,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
|
||||
)
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generateTurn")(function* (request: LLMRequest) {
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
|
||||
@@ -23,10 +23,6 @@ import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages
|
||||
describe("public exports", () => {
|
||||
test("root exposes app-facing runtime APIs", () => {
|
||||
expect(LLM.request).toBeFunction()
|
||||
expect(LLM.generateTurn).toBeFunction()
|
||||
expect(LLM.streamTurn).toBeFunction()
|
||||
expect(LLM).not.toHaveProperty("generate")
|
||||
expect(LLM).not.toHaveProperty("stream")
|
||||
expect(LLMClient.Service).toBeFunction()
|
||||
expect(LLMClient.layer).toBeDefined()
|
||||
expect(ImageInput.bytes).toBeFunction()
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("Cloudflare", () => {
|
||||
|
||||
it.effect("posts to the derived gateway endpoint with bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLM.generateTurn(
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: CloudflareAIGateway.configure({
|
||||
accountId: "test-account",
|
||||
@@ -104,7 +104,7 @@ describe("Cloudflare", () => {
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
const response = yield* LLM.generateTurn(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(
|
||||
@@ -150,7 +150,7 @@ describe("Cloudflare", () => {
|
||||
|
||||
it.effect("supports authenticated AI Gateway plus upstream provider auth", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* LLM.generateTurn(
|
||||
yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: CloudflareAIGateway.configure({
|
||||
accountId: "test-account",
|
||||
@@ -221,7 +221,7 @@ describe("Cloudflare", () => {
|
||||
|
||||
it.effect("posts direct Workers AI requests to the account endpoint with bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLM.generateTurn(
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: CloudflareWorkersAI.configure({
|
||||
accountId: "test-account",
|
||||
@@ -256,7 +256,7 @@ describe("Cloudflare", () => {
|
||||
|
||||
it.effect("supports direct Workers AI token aliases through auth config", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* LLM.generateTurn(
|
||||
yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: CloudflareWorkersAI.configure({
|
||||
accountId: "test-account",
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("OpenAI Responses image generation recorded", () => {
|
||||
partialImages: 0,
|
||||
}),
|
||||
]
|
||||
const response = yield* LLM.generateTurn(
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: openai.responses("gpt-5-mini"),
|
||||
messages: [initial],
|
||||
@@ -49,7 +49,7 @@ describe("OpenAI Responses image generation recorded", () => {
|
||||
expect(result.result.value[0].mime).toBe("image/jpeg")
|
||||
expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true)
|
||||
|
||||
const edited = yield* LLM.generateTurn(
|
||||
const edited = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: openai.responses("gpt-5-mini"),
|
||||
messages: [initial, response.message, Message.user("Now make the triangle blue.")],
|
||||
|
||||
@@ -32,9 +32,9 @@ Tool.make({
|
||||
],
|
||||
})
|
||||
|
||||
LLM.streamTurn(request)
|
||||
LLM.generateTurn(LLMRequest.update(request, { tools: toDefinitions({ schemaOnly }) }))
|
||||
LLM.stream(request)
|
||||
LLM.generate(LLMRequest.update(request, { tools: toDefinitions({ schemaOnly }) }))
|
||||
ToolRuntime.dispatch({ executable }, { type: "tool-call", id: "call_1", name: "executable", input: { city: "Paris" } })
|
||||
|
||||
// @ts-expect-error High-level tool orchestration overloads are intentionally not supported.
|
||||
LLM.streamTurn({ request, tools: { schemaOnly } })
|
||||
LLM.stream({ request, tools: { schemaOnly } })
|
||||
|
||||
@@ -40,7 +40,8 @@ export const layer = Layer.effect(
|
||||
// Drain failures are already logged and durably recorded by the execution layer.
|
||||
yield* Effect.ignore(execution.resume(sessionID))
|
||||
}),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
// Each suspension is consumed atomically right before its drain; at most four drains run at once.
|
||||
{ concurrency: 4, discard: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer, LogLevel } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app"
|
||||
import { ModelV2 } from "../model"
|
||||
@@ -10,6 +10,7 @@ import { PluginHooks } from "../plugin/hooks"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { SessionContext } from "./context"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
@@ -87,6 +88,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const app = yield* App.Metadata
|
||||
const promptCacheSnapshots = new Map<string, PromptCacheDiagnostics.Snapshot>()
|
||||
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
||||
const session = input.context.session
|
||||
@@ -134,6 +136,23 @@ export const layer = Layer.effect(
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
if (yield* LogLevel.isEnabled("Debug")) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
||||
promptCacheSnapshots.delete(session.id)
|
||||
promptCacheSnapshots.set(session.id, current)
|
||||
const oldest = promptCacheSnapshots.keys().next().value
|
||||
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
||||
yield* Effect.logDebug("prompt cache prefix").pipe(
|
||||
Effect.annotateLogs({
|
||||
sessionID: session.id,
|
||||
toolCount: current.tools.length,
|
||||
systemParts: current.system.length,
|
||||
messageCount: current.messages.length,
|
||||
...comparison,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
|
||||
if (stepLimitReached)
|
||||
return Effect.succeed({
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export * as PromptCacheDiagnostics from "./prompt-cache-diagnostics"
|
||||
|
||||
import type { LLMRequest } from "@opencode-ai/ai"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
interface Entry {
|
||||
readonly label: string
|
||||
readonly hash: string
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
readonly settings: string
|
||||
readonly tools: ReadonlyArray<Entry>
|
||||
readonly system: ReadonlyArray<Entry>
|
||||
readonly messages: ReadonlyArray<Entry>
|
||||
}
|
||||
|
||||
export type Comparison =
|
||||
| { readonly status: "initial" }
|
||||
| { readonly status: "stable"; readonly messages: number }
|
||||
| { readonly status: "append-only"; readonly previousMessages: number; readonly currentMessages: number }
|
||||
| {
|
||||
readonly status: "changed"
|
||||
readonly component: "settings" | "tools" | "system" | "messages"
|
||||
readonly index: number
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
const hash = (value: unknown) => Hash.sha256(JSON.stringify(value)).slice(0, 16)
|
||||
|
||||
export function snapshot(request: LLMRequest): Snapshot {
|
||||
return {
|
||||
settings: hash({
|
||||
route: request.model.route.id,
|
||||
provider: request.model.provider,
|
||||
model: request.model.id,
|
||||
modelDefaults: request.model.defaults,
|
||||
compatibility: request.model.compatibility,
|
||||
routeDefaults: {
|
||||
generation: request.model.route.defaults.generation,
|
||||
providerOptions: request.model.route.defaults.providerOptions,
|
||||
http: request.model.route.defaults.http,
|
||||
},
|
||||
generation: request.generation,
|
||||
providerOptions: request.providerOptions,
|
||||
http: request.http,
|
||||
toolChoice: request.toolChoice,
|
||||
cache: request.cache,
|
||||
}),
|
||||
tools: request.tools.map((tool) => ({ label: tool.name, hash: hash(tool) })),
|
||||
system: request.system.map((part, index) => ({ label: `system[${index}]`, hash: hash(part) })),
|
||||
messages: request.messages.map((message, index) => ({
|
||||
label: message.id ?? `${message.role}[${index}]`,
|
||||
hash: hash(message),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function compare(previous: Snapshot | undefined, current: Snapshot): Comparison {
|
||||
if (!previous) return { status: "initial" }
|
||||
if (previous.settings !== current.settings)
|
||||
return {
|
||||
status: "changed",
|
||||
component: "settings",
|
||||
index: 0,
|
||||
label: "model settings",
|
||||
}
|
||||
const tools = firstChange(previous.tools, current.tools, false)
|
||||
if (tools) return { status: "changed", component: "tools", ...tools }
|
||||
const system = firstChange(previous.system, current.system, false)
|
||||
if (system) return { status: "changed", component: "system", ...system }
|
||||
const messages = firstChange(previous.messages, current.messages, true)
|
||||
if (messages) return { status: "changed", component: "messages", ...messages }
|
||||
if (previous.messages.length === current.messages.length)
|
||||
return { status: "stable", messages: current.messages.length }
|
||||
return {
|
||||
status: "append-only",
|
||||
previousMessages: previous.messages.length,
|
||||
currentMessages: current.messages.length,
|
||||
}
|
||||
}
|
||||
|
||||
function firstChange(previous: ReadonlyArray<Entry>, current: ReadonlyArray<Entry>, allowAppend: boolean) {
|
||||
const index = previous.findIndex((entry, index) => entry.hash !== current[index]?.hash)
|
||||
if (index >= 0)
|
||||
return {
|
||||
index,
|
||||
label: current[index]?.label ?? previous[index]?.label ?? `entry[${index}]`,
|
||||
}
|
||||
if (current.length === previous.length || (allowAppend && current.length > previous.length)) return
|
||||
return {
|
||||
index: previous.length,
|
||||
label: current[previous.length]?.label ?? `entry[${previous.length}]`,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent, type ToolCall } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
|
||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect"
|
||||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
@@ -203,9 +203,8 @@ const layer = Layer.effect(
|
||||
context: loaded,
|
||||
step: currentStep,
|
||||
})
|
||||
// Every local tool call forked here is owned until it reaches one durable settlement.
|
||||
const toolRuns: Array<{ readonly call: ToolCall; readonly fiber: Fiber.Fiber<void, ToolOutputStore.Error> }> = []
|
||||
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
|
||||
let needsContinuation = false
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
@@ -278,9 +277,8 @@ const layer = Layer.effect(
|
||||
// continuation depends only on remaining Step allowance.
|
||||
if (!prepared.stepLimitReached) needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
toolRuns.push({
|
||||
call: event,
|
||||
fiber: yield* Effect.uninterruptibleMask((restore) =>
|
||||
ownedToolFibers.push(
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
prepared.executeTool({
|
||||
sessionID: session.id,
|
||||
@@ -292,8 +290,8 @@ const layer = Layer.effect(
|
||||
).pipe(
|
||||
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
|
||||
),
|
||||
).pipe(Effect.forkScoped),
|
||||
})
|
||||
).pipe(FiberSet.run(toolFibers)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
@@ -341,13 +339,13 @@ const layer = Layer.effect(
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
// Settle every owned tool run: await all exits, not just the first failure,
|
||||
// before publishing the terminal step event.
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
|
||||
// the individual fibers and await all exits before publishing the terminal step event.
|
||||
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(
|
||||
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
|
||||
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
|
||||
).pipe(Effect.exit)
|
||||
if (settled._tag === "Failure") yield* interruptTools
|
||||
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
|
||||
const tools = classifyToolExits(settled)
|
||||
|
||||
if (tools.declined || streamInterrupted || tools.interrupted) {
|
||||
@@ -364,17 +362,32 @@ const layer = Layer.effect(
|
||||
// these sweeps only close calls that could not produce a truthful settlement.
|
||||
if (providerFailed)
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
const resultMissing = {
|
||||
type: "tool.result-missing",
|
||||
message: "Provider did not return a tool result",
|
||||
} as const
|
||||
if (llmFailure && !providerFailed) yield* serialized(publisher.failUnsettledTools(resultMissing, "hosted"))
|
||||
// A clean stream that still left hosted calls unresolved fails the step itself.
|
||||
if (stream._tag === "Success" && !providerFailed) {
|
||||
const hostedResultMissing = yield* serialized(publisher.failUnsettledTools(resultMissing, "hosted"))
|
||||
if (hostedResultMissing && !publisher.stepSettlement())
|
||||
yield* serialized(publisher.failAssistant(resultMissing))
|
||||
}
|
||||
if (llmFailure && !providerFailed)
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools(
|
||||
{
|
||||
type: "tool.result-missing",
|
||||
message: "Provider did not return a tool result",
|
||||
},
|
||||
true,
|
||||
),
|
||||
)
|
||||
const hostedResultMissing =
|
||||
stream._tag === "Success" && !providerFailed
|
||||
? yield* serialized(
|
||||
publisher.failUnsettledTools(
|
||||
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
|
||||
true,
|
||||
),
|
||||
)
|
||||
: false
|
||||
if (hostedResultMissing && !publisher.stepSettlement())
|
||||
yield* serialized(
|
||||
publisher.failAssistant({
|
||||
type: "tool.result-missing",
|
||||
message: "Provider did not return a tool result",
|
||||
}),
|
||||
)
|
||||
|
||||
const stepFailure = publisher.stepFailure()
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
|
||||
@@ -277,9 +277,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
|
||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||
error: SessionError.Error,
|
||||
scope: "hosted" | "all" = "all",
|
||||
hostedOnly = false,
|
||||
) {
|
||||
return yield* failTools(error, scope)
|
||||
return yield* failTools(error, hostedOnly ? "hosted" : "all")
|
||||
})
|
||||
|
||||
const assistantMessageIDForTool = (callID: string) => {
|
||||
|
||||
@@ -64,3 +64,29 @@ describe("Npm.add", () => {
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.install", () => {
|
||||
test("respects omit from project .npmrc", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await writePackage(tmp.path, {
|
||||
name: "fixture",
|
||||
dependencies: {
|
||||
"prod-pkg": "file:./prod-pkg",
|
||||
},
|
||||
devDependencies: {
|
||||
"dev-pkg": "file:./dev-pkg",
|
||||
},
|
||||
})
|
||||
await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
|
||||
await fs.mkdir(path.join(tmp.path, "prod-pkg"))
|
||||
await fs.mkdir(path.join(tmp.path, "dev-pkg"))
|
||||
await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
|
||||
await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
|
||||
|
||||
await Npm.install(tmp.path)
|
||||
|
||||
await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
|
||||
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { GenerationOptions, LLM, LLMRequest, Message, Model, ToolDefinition } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
|
||||
|
||||
const model = Model.make({ id: "test", provider: "test", route: OpenAIChat.route })
|
||||
const tool = ToolDefinition.make({
|
||||
name: "read",
|
||||
description: "Read a file",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})
|
||||
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "System",
|
||||
prompt: "First",
|
||||
tools: [tool],
|
||||
})
|
||||
const compare = (current: LLMRequest) =>
|
||||
PromptCacheDiagnostics.compare(PromptCacheDiagnostics.snapshot(request), PromptCacheDiagnostics.snapshot(current))
|
||||
|
||||
describe("PromptCacheDiagnostics", () => {
|
||||
test("distinguishes initial and stable requests", () => {
|
||||
const snapshot = PromptCacheDiagnostics.snapshot(request)
|
||||
expect(PromptCacheDiagnostics.compare(undefined, snapshot)).toEqual({ status: "initial" })
|
||||
expect(PromptCacheDiagnostics.compare(snapshot, snapshot)).toEqual({ status: "stable", messages: 1 })
|
||||
})
|
||||
|
||||
test("recognizes append-only history", () => {
|
||||
const current = LLMRequest.update(request, { messages: [...request.messages, Message.assistant("Second")] })
|
||||
expect(compare(current)).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 2 })
|
||||
})
|
||||
|
||||
test("detects cache-sensitive setting changes", () => {
|
||||
const current = LLMRequest.update(request, { generation: GenerationOptions.make({ temperature: 0.5 }) })
|
||||
expect(compare(current)).toEqual({ status: "changed", component: "settings", index: 0, label: "model settings" })
|
||||
})
|
||||
|
||||
test("finds the first changed prefix component", () => {
|
||||
const changedTool = ToolDefinition.make({ ...tool, description: "Read one file" })
|
||||
const current = LLMRequest.update(request, { tools: [changedTool] })
|
||||
expect(compare(current)).toEqual({ status: "changed", component: "tools", index: 0, label: "read" })
|
||||
})
|
||||
|
||||
test("treats appended tools as a prefix change", () => {
|
||||
const write = ToolDefinition.make({
|
||||
name: "write",
|
||||
description: "Write a file",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})
|
||||
const current = LLMRequest.update(request, { tools: [...request.tools, write] })
|
||||
expect(compare(current)).toEqual({ status: "changed", component: "tools", index: 1, label: "write" })
|
||||
})
|
||||
})
|
||||
@@ -105,31 +105,6 @@ describe("SessionExecution lifecycle", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts every suspended execution without waiting for earlier drains to finish", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionIDs = Array.from({ length: 5 }, (_, index) => SessionV2.ID.make(`ses_resume_concurrent_${index}`))
|
||||
yield* seedSessions(database, sessionIDs, { time_suspended: Date.now() })
|
||||
|
||||
const fourStarted = yield* Deferred.make<void>()
|
||||
const started: SessionV2.ID[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, ({ sessionID }) =>
|
||||
Effect.sync(() => {
|
||||
started.push(sessionID)
|
||||
if (started.length === 4) Deferred.doneUnsafe(fourStarted, Effect.void)
|
||||
}).pipe(Effect.andThen(Effect.never)),
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* restart.resumeSuspendedSessions.pipe(Effect.forkIn(scope))
|
||||
yield* Deferred.await(fourStarted)
|
||||
|
||||
expect([...(yield* execution.active)].toSorted()).toEqual(sessionIDs.toSorted())
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resumes each suspended Session at most once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
@@ -140,11 +115,9 @@ describe("SessionExecution lifecycle", () => {
|
||||
const drained: string[] = []
|
||||
const scope = yield* Scope.make()
|
||||
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
|
||||
expect(drained.toSorted()).toEqual([first, second])
|
||||
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user