mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26fbfa042e | ||
|
|
1383222327 | ||
|
|
095e8074a4 | ||
|
|
1eff84615c | ||
|
|
f1fd6ba3c0 | ||
|
|
39daec9bec | ||
|
|
484f5faf8d | ||
|
|
9a227d186f | ||
|
|
fc27061838 | ||
|
|
d71cc3be77 | ||
|
|
503e680672 | ||
|
|
1f77f4a4ed | ||
|
|
e8fa7daed5 | ||
|
|
936c73b54d | ||
|
|
b9cb4fc36a | ||
|
|
a808a02f05 | ||
|
|
e142a783f7 | ||
|
|
201536f265 | ||
|
|
0d42e76006 | ||
|
|
ca47949475 | ||
|
|
1c8e557eb4 | ||
|
|
11ca603490 | ||
|
|
85d8b07f09 | ||
|
|
d2ee536c16 | ||
|
|
e12e04f482 | ||
|
|
5743537945 | ||
|
|
18e22cd82e | ||
|
|
bdf019a9ac | ||
|
|
5cbafc57c0 | ||
|
|
55674b858b | ||
|
|
bb390f435c | ||
|
|
92b9eebab2 | ||
|
|
074413a96d | ||
|
|
4685ba8d3e |
@@ -177,7 +177,7 @@ const table = sqliteTable("session", {
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and selects capabilities through `SessionStore` plus `SessionInstance.get(session)` only when a drain starts. The server adapter uses `LocationServiceMap.get(session.location)`; direct SDK bindings retain ready instances without a location map. No layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
|
||||
@@ -966,7 +966,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -976,7 +975,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/pacote": "11.1.8",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -5814,7 +5812,7 @@
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
@@ -6644,6 +6642,8 @@
|
||||
|
||||
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
||||
|
||||
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
|
||||
@@ -7450,12 +7450,6 @@
|
||||
|
||||
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -8162,8 +8156,6 @@
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Fmwqp/fjTMX7gQW01Zgta51reLHp1S53GSQgBcJePrY=",
|
||||
"aarch64-linux": "sha256-KLI6OIbvycMePKxt66nNKeILYJSEkufOIaSawZ/gotc=",
|
||||
"aarch64-darwin": "sha256-go1wmrsHfYfJ3ukUIR/fveobyVba4E9G/rdsB/J2cQQ=",
|
||||
"x86_64-darwin": "sha256-UnVYMijlG12k4RLFkTwf4Or0yXqFSwe1Ji6QCGufjzg="
|
||||
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
|
||||
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
|
||||
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
|
||||
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
|
||||
}
|
||||
}
|
||||
|
||||
+29
-11
@@ -214,22 +214,40 @@ the requests sent by code under test:
|
||||
import { Effect } from "effect"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
|
||||
const testLLM = TestLLM.layer({
|
||||
fallback: TestLLM.text("Hello from the test model", "text-1"),
|
||||
})
|
||||
|
||||
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
|
||||
const programWithTestClient = Effect.gen(function* () {
|
||||
const test = yield* TestLLM.Test
|
||||
yield* test.push(TestLLM.text("Hello from the test model", "text-1"))
|
||||
const result = yield* program
|
||||
const test = yield* TestLLM.Service
|
||||
console.log(test.requests)
|
||||
console.log(yield* test.requests())
|
||||
return result
|
||||
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
|
||||
}).pipe(Effect.provide(TestLLM.testLayer()))
|
||||
```
|
||||
|
||||
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
|
||||
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
|
||||
available on the yielded `TestLLM.Service`.
|
||||
`testLayer()` provides the same object under `LLMClient.Service` and `TestLLM.Test`. Production consumes the
|
||||
normal client; tests use the additional controls. Each layer build has fresh state.
|
||||
|
||||
- `test.push(...)` queues one-shot responses in execution order. Each argument is one response.
|
||||
- `test.always(response)` installs a repeatable fallback. The layer's `fallback` option sets its initial value.
|
||||
- `test.serve(request => response)` installs a request-dependent fallback. `always` and `serve` replace each
|
||||
other without changing queued replies; queued replies take precedence.
|
||||
- `test.requests()` returns an array snapshot. `transformRequest` changes only the recorded observation;
|
||||
`serve` receives the original canonical request.
|
||||
- `test.wait(count)` waits for request arrivals, not output or completion, and supports concurrent waiters.
|
||||
- `test.gate()` returns a scoped gate with countable `started` notifications and a `release` Effect. Release
|
||||
unblocks all requests captured by that gate; closing its scope also releases it. Effect-aware test runners
|
||||
already provide Scope.
|
||||
|
||||
Constructing `stream()` or `generate()` does not record a request, invoke a responder, or consume a script.
|
||||
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
|
||||
future reply.
|
||||
|
||||
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
|
||||
it does not repair or truncate them.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
|
||||
## Caching
|
||||
|
||||
|
||||
@@ -496,6 +496,7 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly finishedTools: ReadonlySet<number>
|
||||
// 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.
|
||||
@@ -574,6 +575,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
|
||||
if (event.contentBlockDelta?.delta?.toolUse) {
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
if (state.finishedTools.has(index)) return [state, []] as const
|
||||
const result = ToolStream.appendExisting(
|
||||
ADAPTER,
|
||||
state.tools,
|
||||
@@ -612,6 +614,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
state.hasToolCalls,
|
||||
lifecycle,
|
||||
tools: result.tools,
|
||||
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
|
||||
reasoningSignatures: Object.fromEntries(
|
||||
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
|
||||
),
|
||||
@@ -703,6 +706,7 @@ export const protocol = Protocol.make({
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
finishedTools: new Set<number>(),
|
||||
pendingFinish: undefined,
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
|
||||
@@ -391,17 +391,19 @@ export interface ParserState {
|
||||
readonly name: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<string>
|
||||
// Call ids stay independent of item ids, which may be omitted or reused.
|
||||
readonly completedTools: ReadonlySet<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly open: boolean
|
||||
readonly encryptedContent: string | null | undefined
|
||||
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
@@ -826,16 +828,16 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
|
||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
|
||||
if (!event.delta || state.message?.id !== id) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const phase = state.messagePhases[id]
|
||||
const phase = state.message.phase
|
||||
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
|
||||
}
|
||||
|
||||
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (state.messageItems.has(id)) {
|
||||
if (state.message?.id === id) {
|
||||
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
|
||||
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
|
||||
}
|
||||
@@ -846,18 +848,62 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
export const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item) return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] !== "concluded")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
lifecycle,
|
||||
events,
|
||||
`${itemID}:${index}`,
|
||||
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
|
||||
[itemID]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
|
||||
),
|
||||
),
|
||||
[index]: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item?.open) return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
|
||||
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
|
||||
const current = started.reasoningItems[itemID]
|
||||
if (!current) return [started, emitted]
|
||||
const events: LLMEvent[] = [...emitted]
|
||||
return [
|
||||
{
|
||||
...started,
|
||||
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
|
||||
reasoningItems: {
|
||||
...started.reasoningItems,
|
||||
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
|
||||
},
|
||||
},
|
||||
events,
|
||||
@@ -869,7 +915,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
|
||||
// as a single delta unless that summary index already streamed one.
|
||||
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
@@ -878,32 +924,48 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
|
||||
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
|
||||
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
|
||||
// Responses APIs stream reasoning items in a stable order:
|
||||
// Responses APIs normally stream reasoning items in this order:
|
||||
// `output_item.added` (reasoning) →
|
||||
// `reasoning_summary_part.added` (index=0) →
|
||||
// `reasoning_summary_text.delta` →
|
||||
// `reasoning_summary_part.done` (index=0) →
|
||||
// (repeat for index>0) →
|
||||
// `output_item.done` (reasoning).
|
||||
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
|
||||
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
|
||||
// short-circuits when the entry already exists, and higher-index handlers
|
||||
// fold against the same entry. Behaviour for out-of-order events is
|
||||
// best-effort, not guaranteed.
|
||||
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
|
||||
// also an implicit boundary for the previous part. This keeps the common event
|
||||
// lifecycle ordered when a compatible provider omits or delays a part-done event.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id !== undefined) {
|
||||
const itemID = item.id
|
||||
const phase = messagePhase(item.phase)
|
||||
// A new message closes earlier messages, including ones that never streamed.
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = [...state.lifecycle.text]
|
||||
.filter((id) => id !== itemID)
|
||||
.reduce((lifecycle, id) => {
|
||||
const openPhase = state.message?.id === id ? state.message.phase : undefined
|
||||
return Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
id,
|
||||
providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) }),
|
||||
)
|
||||
}, state.lifecycle)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
|
||||
lifecycle,
|
||||
message: {
|
||||
id: itemID,
|
||||
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
|
||||
},
|
||||
},
|
||||
NO_EVENTS,
|
||||
events,
|
||||
]
|
||||
}
|
||||
if (item && isReasoningItem(item)) {
|
||||
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
@@ -912,6 +974,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: true,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "active" },
|
||||
deltaIndexes: new Set(),
|
||||
@@ -923,6 +986,8 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
}
|
||||
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
|
||||
const id = item.id ?? item.call_id
|
||||
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
|
||||
return [state, NO_EVENTS]
|
||||
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
@@ -943,55 +1008,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
|
||||
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (event.summary_index === 0) return [state, NO_EVENTS]
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const closed = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${entry[0]}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
closed,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
|
||||
),
|
||||
),
|
||||
[event.summary_index]: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (!item?.open) return [state, NO_EVENTS]
|
||||
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1047,11 +1071,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const phase = itemPhase === undefined && state.message?.id === item.id ? state.message.phase : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
messageItems.delete(item.id)
|
||||
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1061,8 +1082,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
item.id,
|
||||
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
message: state.message?.id === item.id ? undefined : state.message,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1070,20 +1090,36 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const id = item.id ?? item.call_id
|
||||
const tools = state.tools[id]
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, id, {
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
|
||||
})
|
||||
const callID = item.call_id
|
||||
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
|
||||
const fallback = item.id ?? callID
|
||||
// Match the pending tool by call id so item events that disagree on
|
||||
// whether `item.id` is present still resolve the same call.
|
||||
const registered =
|
||||
state.tools[fallback] !== undefined
|
||||
? fallback
|
||||
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
|
||||
const id = registered ?? fallback
|
||||
const tools =
|
||||
registered !== undefined
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, id, {
|
||||
id: callID,
|
||||
name: item.name,
|
||||
providerMetadata: metadata,
|
||||
})
|
||||
const result =
|
||||
item.arguments === undefined
|
||||
? yield* ToolStream.finish(state.id, tools, id)
|
||||
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const finished = result.events ?? []
|
||||
// A done-only call never streamed a start event, so open its lifecycle here.
|
||||
const resultEvents =
|
||||
registered !== undefined || finished.length === 0
|
||||
? finished
|
||||
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
|
||||
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
@@ -1094,6 +1130,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasFunctionCall,
|
||||
tools: result.tools,
|
||||
completedTools: new Set([...state.completedTools, callID]),
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1104,20 +1141,49 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
if (reasoningItem) {
|
||||
if (!reasoningItem.open) return [state, NO_EVENTS] satisfies StepResult
|
||||
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
||||
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
...reasoningItem,
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
|
||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "concluded" },
|
||||
deltaIndexes: new Set(),
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
@@ -1139,7 +1205,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
if (
|
||||
id === undefined ||
|
||||
((item.type !== "function_call" || !current.tools[id]) &&
|
||||
(item.type !== "reasoning" || !current.reasoningItems[id]))
|
||||
(item.type !== "reasoning" || !current.reasoningItems[id]?.open))
|
||||
)
|
||||
return Effect.succeed([current, events] satisfies StepResult)
|
||||
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
|
||||
@@ -1256,6 +1322,13 @@ export const step = (state: ParserState, input: Event) => {
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
if (
|
||||
event.item &&
|
||||
isReasoningItem(event.item) &&
|
||||
state.reasoningItems[event.item.id] === undefined &&
|
||||
state.lifecycle.reasoning.size > 0
|
||||
)
|
||||
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
|
||||
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
|
||||
return Effect.succeed(
|
||||
onOutputItemAdded(
|
||||
@@ -1305,10 +1378,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
completedTools: new Set<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
messageItems: new Set<string>(),
|
||||
messagePhases: {},
|
||||
message: undefined,
|
||||
reasoningItems: {},
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
@@ -245,6 +246,8 @@ export const OpenAIChatEvent = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||
const DONE = "[DONE]" as const
|
||||
const OpenAIChatStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(OpenAIChatEvent)])
|
||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||
|
||||
interface PendingToolDelta {
|
||||
@@ -997,33 +1000,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||
|
||||
if (delta?.content) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
// Reasoning is one response-wide channel: it stays open alongside text and
|
||||
// refusal output so late reasoning deltas and details join the same block,
|
||||
// and `finishEvents` closes it once with the complete metadata.
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
|
||||
if (delta?.refusal) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
}
|
||||
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
|
||||
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||
@@ -1129,10 +1111,12 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
|
||||
// Snapshot details at publish time so the emitted event never observes later
|
||||
// mutation of the accumulated `reasoningDetails` array.
|
||||
const metadata = reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
|
||||
)
|
||||
const started =
|
||||
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||
@@ -1166,7 +1150,7 @@ export const protocol = Protocol.make({
|
||||
from: fromRequest,
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||
event: OpenAIChatStreamEvent,
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
@@ -1180,12 +1164,14 @@ export const protocol = Protocol.make({
|
||||
nextToolIndex: 0,
|
||||
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
|
||||
}),
|
||||
step,
|
||||
step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)),
|
||||
terminal: (event) => event === DONE,
|
||||
onHalt: finishEvents,
|
||||
},
|
||||
})
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
|
||||
export const framing = Framing.sseWithDone
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>().with({ framing })
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Route, type RouteRoutedLanguageModelInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import * as OpenAIChat from "./openai-chat.js"
|
||||
|
||||
const ADAPTER = "openai-compatible-chat"
|
||||
@@ -19,7 +18,7 @@ export const route = Route.make({
|
||||
providerMetadataKey: "openai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: Framing.sse,
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
|
||||
|
||||
@@ -207,15 +207,16 @@ export const errorText = (error: unknown) => {
|
||||
|
||||
/**
|
||||
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
|
||||
* decoder, optionally filters named events, and drops empty / `[DONE]`
|
||||
* keep-alive events so the protocol event schema sees one JSON string per
|
||||
* element. Retry control events are ignored without interrupting the stream.
|
||||
* decoder, optionally filters named events, and drops empty events. `[DONE]`
|
||||
* is dropped by default or retained for protocols that use it as their stream
|
||||
* boundary. Retry control events are ignored without interrupting the stream.
|
||||
* Decoder failures become provider output errors so the public error channel
|
||||
* stays `AIError`.
|
||||
*/
|
||||
export const sseFraming = (
|
||||
bytes: Stream.Stream<Uint8Array, AIError>,
|
||||
events?: ReadonlySet<string>,
|
||||
includeDone = false,
|
||||
): Stream.Stream<string, AIError> =>
|
||||
bytes.pipe(
|
||||
Stream.decodeText(),
|
||||
@@ -240,7 +241,7 @@ export const sseFraming = (
|
||||
(event) =>
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
event.data.length > 0 &&
|
||||
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
|
||||
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
|
||||
),
|
||||
Stream.map((event) => event.data),
|
||||
)
|
||||
|
||||
@@ -62,22 +62,31 @@ export const reasoningEnd = (
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
/** Authoritative complete value; replaces accumulated deltas when present. */
|
||||
text?: string,
|
||||
): State => {
|
||||
if (!state.reasoning.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id, text, providerMetadata }))
|
||||
const reasoning = new Set(stepped.reasoning)
|
||||
reasoning.delete(id)
|
||||
return { ...stepped, reasoning }
|
||||
}
|
||||
|
||||
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
export const textEnd = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
/** Authoritative complete value; replaces accumulated deltas when present. */
|
||||
text?: string,
|
||||
): State => {
|
||||
if (!state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
||||
const text = new Set(stepped.text)
|
||||
text.delete(id)
|
||||
return { ...stepped, text }
|
||||
events.push(LLMEvent.textEnd({ id, text, providerMetadata }))
|
||||
const open = new Set(stepped.text)
|
||||
open.delete(id)
|
||||
return { ...stepped, text: open }
|
||||
}
|
||||
|
||||
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
|
||||
|
||||
@@ -37,6 +37,7 @@ const patterns = [
|
||||
/too large for model with \d+ maximum context length/i,
|
||||
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
|
||||
/model_context_window_exceeded/i,
|
||||
/range of input length should be/i,
|
||||
/too many tokens/i,
|
||||
/token limit exceeded/i,
|
||||
/request_too_large/i,
|
||||
@@ -59,6 +60,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
"internal_error",
|
||||
@@ -74,7 +76,6 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
@@ -90,8 +91,12 @@ export interface ProviderFailure {
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
}
|
||||
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
// Classification records affirmative evidence about a failure. Deterministic
|
||||
// failures need positive identification (a 4xx status, quota/auth/policy
|
||||
// signals); anything unrecognized stays UnknownProvider, which the session
|
||||
// retry policy treats as retry-eligible because transient failures arrive in
|
||||
// unpredictable shapes while deterministic rejections almost always carry a
|
||||
// status or known code.
|
||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||
const details = { message: input.message, body: input.rawBody, http: input.http, cause: input.cause }
|
||||
const body = input.rawBody ?? ""
|
||||
@@ -116,46 +121,33 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401) return new AuthenticationError({ ...details, kind: "invalid" })
|
||||
if (input.status === 403) return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
|
||||
if (codes.includes("authentication_error")) return new AuthenticationError({ ...details, kind: "invalid" })
|
||||
if (codes.includes("permission_error"))
|
||||
return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
return new AuthenticationError(details)
|
||||
if (
|
||||
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
|
||||
input.status === 429 ||
|
||||
codes.some(
|
||||
(code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception",
|
||||
) ||
|
||||
RATE_LIMIT_TEXT.test(text)
|
||||
)
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (RATE_LIMIT_TEXT.test(text))
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalError(details)
|
||||
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (input.status === 429) {
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
}
|
||||
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
|
||||
if (
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestError(details)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
return new InvalidRequestError(details)
|
||||
// Any remaining 4xx is a deterministic rejection of this request.
|
||||
if (input.status !== undefined && input.status >= 400 && input.status < 500) return new InvalidRequestError(details)
|
||||
return new UnknownProviderError(details)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ProviderShared } from "../protocols/shared.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
@@ -75,7 +74,7 @@ export const route = Route.make({
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
|
||||
framing: Framing.sse,
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
|
||||
@@ -167,7 +166,7 @@ export const route = Route.make({
|
||||
providerMetadataKey: "openrouter",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
|
||||
framing: Framing.sse,
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -139,7 +139,7 @@ const toAIError = (error: AuthError): AIError => {
|
||||
return new AIError({
|
||||
reason:
|
||||
error instanceof MissingCredentialError
|
||||
? new AuthenticationError({ message: error.message, cause: error, kind: "missing" })
|
||||
? new AuthenticationError({ message: error.message, cause: error })
|
||||
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { AIError } from "../schema/index.js"
|
||||
* `Framing` is the byte-stream-shaped seam between transport and protocol:
|
||||
*
|
||||
* - SSE (`Framing.sse`) — UTF-8 decode the body, run the SSE channel decoder,
|
||||
* drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:`
|
||||
* payload of one event.
|
||||
* and emit the `data:` payload of each non-empty event. The default drops
|
||||
* `[DONE]`; protocols that use it as a terminal select `sseWithDone`.
|
||||
* - AWS event stream — length-prefixed binary frames with CRC checksums.
|
||||
* Each emitted frame is one parsed binary event record.
|
||||
*
|
||||
@@ -26,6 +26,12 @@ export interface Definition<Frame> {
|
||||
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
||||
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
|
||||
|
||||
/** Server-Sent Events framing that retains the conventional `[DONE]` sentinel. */
|
||||
export const sseWithDone: Definition<string> = {
|
||||
id: "sse",
|
||||
frame: (bytes) => ProviderShared.sseFraming(bytes, undefined, true),
|
||||
}
|
||||
|
||||
/** SSE framing restricted to protocol-recognized event names. */
|
||||
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
|
||||
id: "sse",
|
||||
|
||||
@@ -44,10 +44,7 @@ export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoR
|
||||
|
||||
export class AuthenticationError extends Schema.TaggedError<AuthenticationError>("AI.Error.Authentication")(
|
||||
"Authentication",
|
||||
{
|
||||
...ReasonFields,
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
},
|
||||
ReasonFields,
|
||||
) {}
|
||||
|
||||
export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
|
||||
|
||||
@@ -112,6 +112,8 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||
export const TextEnd = Schema.Struct({
|
||||
type: Schema.tag("text-end"),
|
||||
id: ContentBlockID,
|
||||
/** Authoritative complete value; replaces accumulated deltas when present. */
|
||||
text: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
||||
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||
@@ -134,6 +136,8 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||
export const ReasoningEnd = Schema.Struct({
|
||||
type: Schema.tag("reasoning-end"),
|
||||
id: ContentBlockID,
|
||||
/** Authoritative complete value; replaces accumulated deltas when present. */
|
||||
text: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||
@@ -328,17 +332,32 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
})
|
||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||
|
||||
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
|
||||
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
|
||||
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
|
||||
) => {
|
||||
const order: string[] = []
|
||||
const parts = new Map<string, string>()
|
||||
for (const event of events) {
|
||||
if (isDelta(event)) {
|
||||
if (!parts.has(event.id)) order.push(event.id)
|
||||
parts.set(event.id, (parts.get(event.id) ?? "") + event.text)
|
||||
}
|
||||
if (isEnd(event) && event.text !== undefined) {
|
||||
if (!parts.has(event.id)) order.push(event.id)
|
||||
parts.set(event.id, event.text)
|
||||
}
|
||||
}
|
||||
return order.map((id) => parts.get(id)).join("")
|
||||
}
|
||||
|
||||
const responseText = (events: ReadonlyArray<LLMEvent>) =>
|
||||
events
|
||||
.filter(LLMEvent.is.textDelta)
|
||||
.map((event) => event.text)
|
||||
.join("")
|
||||
joinFragments(events, LLMEvent.is.textDelta, LLMEvent.is.textEnd)
|
||||
|
||||
const responseReasoning = (events: ReadonlyArray<LLMEvent>) =>
|
||||
events
|
||||
.filter(LLMEvent.is.reasoningDelta)
|
||||
.map((event) => event.text)
|
||||
.join("")
|
||||
joinFragments(events, LLMEvent.is.reasoningDelta, LLMEvent.is.reasoningEnd)
|
||||
|
||||
const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
|
||||
events.reduce<Usage | undefined>(
|
||||
@@ -445,10 +464,11 @@ const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState
|
||||
const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
|
||||
const current = state.textParts[event.id]
|
||||
if (!current) return state
|
||||
const text = event.text ?? current.text
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
return {
|
||||
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
|
||||
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
|
||||
...replaceContent(state, current.contentIndex, textContent(text, providerMetadata)),
|
||||
textParts: { ...state.textParts, [event.id]: { ...current, text, providerMetadata } },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,10 +498,11 @@ const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): Resp
|
||||
const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => {
|
||||
const current = state.reasoningParts[event.id]
|
||||
if (!current) return state
|
||||
const text = event.text ?? current.text
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
return {
|
||||
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
|
||||
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
|
||||
...replaceContent(state, current.contentIndex, reasoningContent(text, providerMetadata)),
|
||||
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,12 +600,12 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
|
||||
usage: Schema.optional(Usage),
|
||||
finishReason: FinishReasonDetails,
|
||||
}) {
|
||||
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
||||
/** Concatenated assistant text; each fragment's `text-end` value replaces its accumulated deltas when present. */
|
||||
get text() {
|
||||
return responseText(this.events)
|
||||
}
|
||||
|
||||
/** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */
|
||||
/** Concatenated reasoning text; each fragment's `reasoning-end` value replaces its accumulated deltas when present. */
|
||||
get reasoning() {
|
||||
return responseReasoning(this.events)
|
||||
}
|
||||
|
||||
+103
-52
@@ -1,6 +1,6 @@
|
||||
export * as TestLLM from "./testing.js"
|
||||
|
||||
import { LLMClient, type Interface as LLMClientShape } from "./route/client.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
@@ -16,13 +16,33 @@ export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
type ClientInterface = Context.Service.Shape<typeof LLMClient.Service>
|
||||
|
||||
export type Responder = (request: LLMRequest) => Response
|
||||
|
||||
export interface TestInterface extends ClientInterface {
|
||||
/** Returns a snapshot of requests observed at execution time. */
|
||||
readonly requests: () => Effect.Effect<readonly LLMRequest[]>
|
||||
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||
/** Replaces the fallback without changing queued responses. */
|
||||
readonly always: (response: Response) => Effect.Effect<void>
|
||||
/** Answers requests after the one-shot queue is exhausted; receives the original request. */
|
||||
readonly serve: (responder: Responder) => Effect.Effect<void>
|
||||
/** Waits for request arrivals, not output or completion. */
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestLLM/Test") {}
|
||||
|
||||
/** @deprecated Use TestInterface through Test and testLayer. */
|
||||
export interface Interface {
|
||||
readonly requests: LLMRequest[]
|
||||
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||
readonly always: (response: Response) => Effect.Effect<void>
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
|
||||
readonly client: LLMClientShape
|
||||
readonly client: ClientInterface
|
||||
}
|
||||
|
||||
export interface LayerOptions {
|
||||
@@ -31,6 +51,7 @@ export interface LayerOptions {
|
||||
readonly fallback?: Response
|
||||
}
|
||||
|
||||
/** @deprecated Use Test and testLayer for normal client methods and test controls. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
|
||||
|
||||
export const complete = (
|
||||
@@ -80,59 +101,64 @@ export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Strea
|
||||
|
||||
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
|
||||
|
||||
export const layer = (options: LayerOptions = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const requests: LLMRequest[] = []
|
||||
const responses: Response[] = []
|
||||
let started = Deferred.makeUnsafe<void>()
|
||||
let fallback = options.fallback
|
||||
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
const make = (options: LayerOptions) =>
|
||||
Effect.sync(() => {
|
||||
const requests: LLMRequest[] = []
|
||||
const responses: Response[] = []
|
||||
let started = Deferred.makeUnsafe<void>()
|
||||
let fallback: Response | Responder | undefined = options.fallback
|
||||
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
|
||||
const stream = ((request: LLMRequest) => {
|
||||
requests.push(options.transformRequest?.(request) ?? request)
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.suspend(() => {
|
||||
const count = requests.push(options.transformRequest?.(request) ?? request)
|
||||
const waiting = started
|
||||
started = Deferred.makeUnsafe()
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
const response = responses.shift() ?? fallback
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`))
|
||||
const streamed = toStream(response)
|
||||
const gate = activeGate
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
}) as LLMClientShape["stream"]
|
||||
const client = LLMClient.Service.of({
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||
}),
|
||||
),
|
||||
try {
|
||||
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
const streamed = toStream(response)
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
} finally {
|
||||
// Waiters can resume synchronously; assign the reply and gate before notifying them.
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
requests,
|
||||
push: (...input) =>
|
||||
Effect.sync(() => {
|
||||
responses.push(...input)
|
||||
const test = Test.of({
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||
}),
|
||||
always: (response) =>
|
||||
Effect.sync(() => {
|
||||
fallback = response
|
||||
}),
|
||||
wait,
|
||||
gate: Effect.gen(function* () {
|
||||
),
|
||||
requests: () => Effect.sync(() => [...requests]),
|
||||
push: (...input) =>
|
||||
Effect.sync(() => {
|
||||
responses.push(...input)
|
||||
}),
|
||||
always: (response) =>
|
||||
Effect.sync(() => {
|
||||
fallback = response
|
||||
}),
|
||||
serve: (responder) =>
|
||||
Effect.sync(() => {
|
||||
fallback = responder
|
||||
}),
|
||||
wait,
|
||||
gate: () =>
|
||||
Effect.gen(function* () {
|
||||
const gate = {
|
||||
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
|
||||
release: yield* Latch.make(),
|
||||
@@ -147,11 +173,36 @@ export const layer = (options: LayerOptions = {}) =>
|
||||
release,
|
||||
}
|
||||
}),
|
||||
client,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
return { test, requests }
|
||||
})
|
||||
|
||||
/** Provides one shared implementation under the normal client and test-control tags. */
|
||||
export const testLayer = (options: LayerOptions = {}) =>
|
||||
Layer.effectContext(
|
||||
Effect.map(make(options), (implementation) =>
|
||||
Context.make(LLMClient.Service, implementation.test).pipe(Context.add(Test, implementation.test)),
|
||||
),
|
||||
)
|
||||
|
||||
/** @deprecated Use testLayer; retained for published callers of the legacy control interface. */
|
||||
export const layer = (options: LayerOptions = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.map(make(options), (implementation) =>
|
||||
Service.of({
|
||||
requests: implementation.requests,
|
||||
push: implementation.test.push,
|
||||
always: implementation.test.always,
|
||||
wait: implementation.test.wait,
|
||||
gate: implementation.test.gate(),
|
||||
client: implementation.test,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
/** @deprecated testLayer provides LLMClient.Service directly. */
|
||||
export const clientLayer = Layer.effect(
|
||||
LLMClient.Service,
|
||||
Effect.map(Service, (service) => service.client),
|
||||
|
||||
@@ -574,6 +574,7 @@ describe("WebSocket channel execution", () => {
|
||||
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
|
||||
const request = LLM.request({ model, prompt: "Say hello." })
|
||||
const frames = [
|
||||
JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_1" } }),
|
||||
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
]
|
||||
|
||||
@@ -32,6 +32,8 @@ describe("public exports", () => {
|
||||
expect(Provider.make).toBeFunction()
|
||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||
expect(TestLLM.layer).toBeFunction()
|
||||
expect(TestLLM.testLayer).toBeFunction()
|
||||
expect(TestLLM.Test.of).toBeFunction()
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
@@ -29,7 +29,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
@@ -29,7 +29,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
Vendored
+1
-1
@@ -24,7 +24,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
@@ -41,7 +41,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":\"Sure! Let me check the weather in Paris for you right now!\",\"tool_calls\":[{\"id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErkCCosBCA8YAipAjKnRKpxkZ4eHrMPJ63IWEOYPSzb+XSHyG+vLK+2ks2O9T4N9M37Xn2kausQSH1rfsrdmKxgUlBg6yUFRgMVR7DIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMNFb5O6pb4nX0HojdGgyL5h+CAIpsxpdM1QgiMGm/i3ST6F5mAhxB+Uez0Cm95ra9yvQkrzHaA/AmWoXpdmPlczSn1S1RDk2IqeA57Spbf7JT44jygtLQt6yZmGzoTBHn3VkwaNZsuuAtbdo4B5QJXooa/AoKKs54QZ2kfS640vsv5flQVCg7CoQCFuLKjIeLMO7MnxVyuskXJr1DgesTa7I0ScF53U9JGhgB\"}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":\"Sure! Let me check the weather in Paris for you right now!\",\"tool_calls\":[{\"id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"The user wants to know the weather in Paris. I'll use the get_weather tool.\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErkCCosBCA8YAipAjKnRKpxkZ4eHrMPJ63IWEOYPSzb+XSHyG+vLK+2ks2O9T4N9M37Xn2kausQSH1rfsrdmKxgUlBg6yUFRgMVR7DIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMNFb5O6pb4nX0HojdGgyL5h+CAIpsxpdM1QgiMGm/i3ST6F5mAhxB+Uez0Cm95ra9yvQkrzHaA/AmWoXpdmPlczSn1S1RDk2IqeA57Spbf7JT44jygtLQt6yZmGzoTBHn3VkwaNZsuuAtbdo4B5QJXooa/AoKKs54QZ2kfS640vsv5flQVCg7CoQCFuLKjIeLMO7MnxVyuskXJr1DgesTa7I0ScF53U9JGhgB\"}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01PaChhcyw3yu2P2bDS2bgAA\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"temperature\":0,\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},\"title\":\"verification.pdf\"}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"url": "https://api.anthropic.com/v1/messages?beta=true",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"},\"title\":\"verification.pdf\"},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -11,6 +11,7 @@ describe("provider error classification", () => {
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
|
||||
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
|
||||
"Range of input length should be [1, 129024]",
|
||||
"Too many tokens",
|
||||
"Token limit exceeded",
|
||||
]
|
||||
@@ -87,10 +88,12 @@ describe("provider error classification", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("classifies network error text as provider internal", () => {
|
||||
test("classifies any remaining 4xx status as an invalid request", () => {
|
||||
expect(
|
||||
["network error", "network-error", "network_error"].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
[400, 402, 404, 418, 422, 451].map(
|
||||
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
|
||||
),
|
||||
).toEqual(Array(6).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
@@ -103,10 +106,12 @@ describe("provider error classification", () => {
|
||||
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"])
|
||||
})
|
||||
|
||||
test("keeps unknown and malformed provider payloads non-retryable", () => {
|
||||
test("leaves unrecognized failures unclassified for the retry default", () => {
|
||||
expect(classifyProviderFailure({ message: '{"error":{"message":"no_kv_space"}}' })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "network error" })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "Provider returned error" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { model } from "@opencode-ai/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
@@ -36,7 +38,8 @@ describe("provider package entrypoints", () => {
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[8].model).toBe(modules[9].model)
|
||||
expect(modules[12].model).toBe(modules[13].model)
|
||||
expect(modules[19].model).toBe(modules[20].model)
|
||||
expect(modules[19].model).toBe(modules[21].model)
|
||||
expect(modules[19].model).not.toBe(modules[20].model)
|
||||
})
|
||||
|
||||
test("maps DeepInfra package settings onto its native executable model", async () => {
|
||||
@@ -139,8 +142,10 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
path: "/messages",
|
||||
})
|
||||
expect(
|
||||
Endpoint.render(selected.route.endpoint, { request: LLM.request({ model: selected }), body: {} }).toString(),
|
||||
).toBe("https://messages.example.test/v1/messages")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
|
||||
|
||||
@@ -491,6 +491,59 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores late tool deltas after contentBlockStop", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
[
|
||||
"contentBlockStart",
|
||||
{
|
||||
contentBlockIndex: 0,
|
||||
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
|
||||
},
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"weather"}' } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"late":true}' } } }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.toolCalls).toEqual([
|
||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "tool_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tool deltas without contentBlockStart", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Bedrock Converse tool delta is missing its tool call",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers incomplete tool input at finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("provider error retention", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.message).toContain("Slow down")
|
||||
expect(error.reason._tag).toBe(entry.name === "Gemini" ? "ProviderInternal" : "RateLimit")
|
||||
expect(error.reason._tag).toBe("RateLimit")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
|
||||
expect(error.reason.http?.url).toStartWith("https://provider.test/")
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const request = LLM.request({
|
||||
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
prompt: "Respond.",
|
||||
})
|
||||
const completed = { type: "response.completed", response: { id: "resp_1" } }
|
||||
|
||||
const collect = (...input: OpenResponses.Event[]) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* LLMClient.stream(request).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.provide(fixedResponse(sseEvents(...input))),
|
||||
)
|
||||
expectLifecycle(
|
||||
events,
|
||||
input.some((event) => event.type === "response.completed"),
|
||||
)
|
||||
return events
|
||||
})
|
||||
|
||||
// Deliberately local to these basic-item fixtures, not a general stream validator.
|
||||
function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
|
||||
const active = { text: new Set<string>(), reasoning: new Set<string>() }
|
||||
const tools = new Map<string, "started" | "ended" | "called">()
|
||||
events.forEach((event) => {
|
||||
if (event.type === "text-start" || event.type === "reasoning-start") {
|
||||
const blocks = event.type === "text-start" ? active.text : active.reasoning
|
||||
expect(blocks.size).toBe(0)
|
||||
blocks.add(event.id)
|
||||
}
|
||||
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
||||
expect((event.type === "text-delta" ? active.text : active.reasoning).has(event.id)).toBe(true)
|
||||
}
|
||||
if (event.type === "text-end" || event.type === "reasoning-end") {
|
||||
expect((event.type === "text-end" ? active.text : active.reasoning).delete(event.id)).toBe(true)
|
||||
}
|
||||
if (event.type === "tool-input-start") {
|
||||
expect(tools.has(event.id)).toBe(false)
|
||||
tools.set(event.id, "started")
|
||||
}
|
||||
if (event.type === "tool-input-delta") expect(tools.get(event.id)).toBe("started")
|
||||
if (event.type === "tool-input-end") {
|
||||
expect(tools.get(event.id)).toBe("started")
|
||||
tools.set(event.id, "ended")
|
||||
}
|
||||
if (event.type === "tool-call") {
|
||||
expect(tools.get(event.id)).toBe("ended")
|
||||
tools.set(event.id, "called")
|
||||
}
|
||||
// Incomplete responses may leave pending tool inputs without a call.
|
||||
if (event.type === "finish" && completed) {
|
||||
expect(active.text.size).toBe(0)
|
||||
expect(active.reasoning.size).toBe(0)
|
||||
expect([...tools.values()].every((status) => status === "called")).toBe(true)
|
||||
}
|
||||
})
|
||||
expect(events.filter(LLMEvent.is.stepStart)).toHaveLength(1)
|
||||
expect(events[0]?.type).toBe("step-start")
|
||||
expect(events.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
|
||||
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
expect(events.slice(-2).map((event) => event.type)).toEqual(["step-finish", "finish"])
|
||||
}
|
||||
|
||||
describe("Open Responses basic-item lifecycles", () => {
|
||||
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { ...item, encrypted_content: null } },
|
||||
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "Second" },
|
||||
// The third part omits both explicit summary boundaries.
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
output_index: 0,
|
||||
item_id: "wrong",
|
||||
summary_index: 2,
|
||||
delta: "Third",
|
||||
},
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
|
||||
completed,
|
||||
)
|
||||
|
||||
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:2", text: "Third" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves done-only encrypted reasoning without replaying its summary or late events", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Not streamed" }],
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
|
||||
completed,
|
||||
// Route termination must also prevent events after response completion.
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
|
||||
)
|
||||
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forgets never-streamed messages at implicit boundaries and preserves refusal phases", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_empty" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "Checking" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "Duplicate" },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_2", phase: null } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_empty", delta: "stale" },
|
||||
{ type: "response.output_text.done", item_id: "msg_empty", text: "stale final" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "late" },
|
||||
{ type: "response.refusal.delta", item_id: "msg_2", delta: "Cannot help." },
|
||||
{ type: "response.refusal.done", item_id: "msg_2", refusal: "Cannot help." },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_2", phase: "final_answer" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_3", phase: null } },
|
||||
{ type: "response.refusal.done", item_id: "msg_3", refusal: "Done-only refusal." },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_3" } },
|
||||
completed,
|
||||
)
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_1", text: "Checking" },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_2",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: null } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_2", text: "Cannot help." },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_2",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_3",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_3", text: "Done-only refusal." },
|
||||
{ type: "text-end", id: "msg_3", providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
|
||||
completed,
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
|
||||
}),
|
||||
)
|
||||
;[undefined, "fc_1"].forEach((id) => {
|
||||
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "function_call",
|
||||
...(id === undefined ? {} : { id }),
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
|
||||
{ type: "response.output_item.added", item },
|
||||
completed,
|
||||
)
|
||||
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
|
||||
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.finish)).toEqual([
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
|
||||
const first = { ...item, ...(id === undefined ? {} : { id }) }
|
||||
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: first },
|
||||
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
|
||||
{ type: "response.output_item.added", item: duplicate },
|
||||
{ type: "response.output_item.done", item: duplicate },
|
||||
{ type: "response.output_item.done", item: first },
|
||||
{ type: "response.output_item.added", item: duplicate },
|
||||
completed,
|
||||
)
|
||||
// Identity metadata comes from the first admission, not the duplicate.
|
||||
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
|
||||
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("recovers pending items in completed output order with terminal encrypted metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"draft"}' },
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [
|
||||
{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" },
|
||||
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: '{"query":"final"}' },
|
||||
{ type: "function_call", id: "fc_unseen", call_id: "call_unseen", name: "lookup", arguments: "{}" },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(events.slice(5, -2)).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "final" },
|
||||
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves call identity and pending order when an item id is reused", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: first },
|
||||
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
|
||||
{ type: "response.output_item.done", item: first },
|
||||
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
|
||||
{ type: "response.output_item.done", item: first },
|
||||
completed,
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "" } },
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "", delta: "Thinking" },
|
||||
{ type: "response.output_text.delta", item_id: "", delta: "Answer" },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "", encrypted_content: "state" } },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "" } },
|
||||
completed,
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual(["Thinking"])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Answer"])
|
||||
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: ":0",
|
||||
providerMetadata: { "openai-compatible": { itemId: "", reasoningEncryptedContent: "state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover a completed tool from a tracked message with the same id", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "item_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "item_1", delta: "Answer" },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "{}" }],
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||
expect(events.filter(LLMEvent.is.finish).map((event) => event.reason.normalized)).toEqual(["stop"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flushes pending calls and open text when completed output is absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "final_answer" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
// Generic terminal closure does not repeat the message's phase metadata.
|
||||
expect(events.slice(4, -2)).toEqual([
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not reconcile pending calls or terminal reasoning metadata on incomplete responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Partial" },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":' },
|
||||
{
|
||||
type: "response.incomplete",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
incomplete_details: { reason: "max_output_tokens" },
|
||||
output: [
|
||||
{ type: "reasoning", id: "rs_1", encrypted_content: "not-reconciled" },
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"not-reconciled"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(events.filter(LLMEvent.is.toolInputEnd)).toEqual([])
|
||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1:0" }])
|
||||
expect(events.filter(LLMEvent.is.finish)).toEqual([
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "length", raw: "max_output_tokens" },
|
||||
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
LanguageModel,
|
||||
ToolCallPart,
|
||||
@@ -807,6 +808,28 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finishes at the done sentinel without waiting for response EOF", () =>
|
||||
Effect.gen(function* () {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "stop"))),
|
||||
)
|
||||
},
|
||||
})
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(stream, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.events.at(-1)?.type).toBe("finish")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves streamed refusals as ordinary assistant text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1127,7 +1150,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves scalar reasoning after content starts", () =>
|
||||
it.effect("preserves scalar reasoning after content starts in one lifecycle", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1144,8 +1167,35 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("detailscalar")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps one reasoning lifecycle across many content chunks", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
...Array.from({ length: 25 }, (_, index) => deltaChunk({ content: `chunk-${index} ` })),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe(Array.from({ length: 25 }, (_, index) => `chunk-${index} `).join(""))
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
@@ -1191,7 +1241,18 @@ describe("OpenAI Chat route", () => {
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
// Snapshot reasoning-end metadata as each event is published so the
|
||||
// assertion cannot pass through later mutation of a shared array.
|
||||
const publishedEndMetadata: unknown[] = []
|
||||
const response = yield* LLMClient.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (LLMEvent.is.reasoningEnd(event))
|
||||
publishedEndMetadata.push(decodeJson(encodeJson(event.providerMetadata)))
|
||||
}),
|
||||
),
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.map((state) => LLMResponse.complete(state)!),
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
@@ -1212,10 +1273,12 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
|
||||
expect(publishedEndMetadata).toEqual([{ openai: { reasoningField: "reasoning", reasoningDetails: merged } }])
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningStart)).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
// Reasoning stays open alongside text and closes once during finalization.
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeGreaterThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
|
||||
|
||||
@@ -405,6 +405,19 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores events after the done sentinel", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = `${sseEvents(
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
)}data: ${JSON.stringify(deltaChunk({ content: " late" }))}\n\n`
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts nullable usage and preserves provider fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -2750,6 +2750,227 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("concludes reasoning at implicit summary boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
// The next part is enough to conclude the previous one even when
|
||||
// its done event is delayed.
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
|
||||
// Some compatible providers begin the next part with its first delta.
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Third" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("FirstSecondThird")
|
||||
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:2", text: "Third", providerMetadata: undefined },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a reasoning item that starts before the previous item ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_2" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("started reasoning before the previous item ended")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("concludes text at implicit message boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
// An item that never streams text is untracked at the boundary too.
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_0" } },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
|
||||
// The previous message's done event is missing; the next message
|
||||
// item is the boundary for its open text.
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
|
||||
// Late deltas for concluded or untracked messages must stay no-ops.
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: " late" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_0", delta: " stale" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("FirstSecond")
|
||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toMatchObject([
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_2" },
|
||||
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||
{ type: "text-end", id: "msg_2" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("opens the tool lifecycle for a done-only function call", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
// No output_item.added: the call arrives only as a completed item.
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type.startsWith("tool-"))).toMatchObject([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores duplicate item boundary events", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
// Duplicate added for a known item is not overlap and must no-op.
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"weather"}' },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
},
|
||||
// Duplicates that drop the item id still resolve the same call.
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Think")
|
||||
expect(response.events.filter((event) => event.type === "reasoning-start")).toHaveLength(1)
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
|
||||
expect(response.events.filter((event) => event.type === "tool-input-start")).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
|
||||
@@ -305,9 +305,11 @@ describe("OpenRouter", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
content: "",
|
||||
reasoning: "Thinking",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -335,7 +337,14 @@ describe("OpenRouter", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Thinking",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -361,7 +370,14 @@ describe("OpenRouter", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "AB",
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -376,7 +392,16 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: undefined,
|
||||
reasoning_content: undefined,
|
||||
reasoning_details: undefined,
|
||||
reasoning_text: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -104,6 +104,60 @@ describe("LLMResponse reducer", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("authoritative text-end value replaces accumulated deltas", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "t1" }),
|
||||
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
|
||||
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
|
||||
expect(response?.text).toBe("Hello!")
|
||||
})
|
||||
|
||||
test("text-end without value keeps joined deltas", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "t1" }),
|
||||
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
|
||||
LLMEvent.textDelta({ id: "t1", text: "lo" }),
|
||||
LLMEvent.textEnd({ id: "t1" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([{ type: "text", text: "Hello" }])
|
||||
expect(response?.text).toBe("Hello")
|
||||
})
|
||||
|
||||
test("authoritative reasoning-end value replaces only its own fragment", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.reasoningStart({ id: "r1:0" }),
|
||||
LLMEvent.reasoningDelta({ id: "r1:0", text: "First summ" }),
|
||||
LLMEvent.reasoningEnd({ id: "r1:0", text: "First summary." }),
|
||||
LLMEvent.reasoningStart({ id: "r1:1" }),
|
||||
LLMEvent.reasoningDelta({ id: "r1:1", text: "Second summary." }),
|
||||
LLMEvent.reasoningEnd({ id: "r1:1" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([
|
||||
{ type: "reasoning", text: "First summary." },
|
||||
{ type: "reasoning", text: "Second summary." },
|
||||
])
|
||||
expect(response?.reasoning).toBe("First summary.Second summary.")
|
||||
})
|
||||
|
||||
test("end value recovers a fragment that streamed no deltas", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "t1" }),
|
||||
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
|
||||
expect(response?.text).toBe("Hello!")
|
||||
})
|
||||
|
||||
test("clears malformed tool input without appending an executable call", () => {
|
||||
const state = reduce([
|
||||
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||
|
||||
@@ -250,7 +250,7 @@ test("AI error reasons are tagged Errors with required messages", () => {
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
}),
|
||||
new AuthenticationError({ message: "Missing credentials", kind: "missing" }),
|
||||
new AuthenticationError({ message: "Missing credentials" }),
|
||||
new RateLimitError({ message: "Rate limited" }),
|
||||
new QuotaExceededError({ message: "Quota exceeded" }),
|
||||
new ContentPolicyError({ message: "Content blocked" }),
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
|
||||
import { OpenAIChat } from "../src/protocols/openai-chat.js"
|
||||
import { TestLLM } from "../src/testing.js"
|
||||
import { Effect, Fiber, Latch, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect.js"
|
||||
|
||||
const request = LLM.request({
|
||||
model: LanguageModel.make({ id: "fictional-model", provider: "fixture", route: OpenAIChat.route }),
|
||||
prompt: "Say hello",
|
||||
})
|
||||
const legacy = testEffect(TestLLM.layer())
|
||||
const it = testEffect(TestLLM.testLayer())
|
||||
|
||||
describe("TestLLM legacy client", () => {
|
||||
legacy.effect("does not observe requests or consume responses until execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
|
||||
llm.client.stream(request)
|
||||
llm.client.generate(request)
|
||||
expect(llm.requests).toEqual([])
|
||||
|
||||
expect((yield* llm.client.generate(request)).text).toBe("first")
|
||||
expect((yield* llm.client.generate(request)).text).toBe("second")
|
||||
expect(llm.requests).toEqual([request, request])
|
||||
}),
|
||||
)
|
||||
|
||||
legacy.effect("assigns and records a fresh response for each execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
yield* llm.push(
|
||||
TestLLM.text("first", "first"),
|
||||
TestLLM.text("second", "second"),
|
||||
TestLLM.text("third", "third"),
|
||||
TestLLM.text("fourth", "fourth"),
|
||||
)
|
||||
const stream = llm.client.stream(request)
|
||||
const generate = llm.client.generate(request)
|
||||
|
||||
expect(yield* Stream.runCollect(stream)).toEqual(TestLLM.text("first", "first"))
|
||||
expect(yield* Stream.runCollect(stream)).toEqual(TestLLM.text("second", "second"))
|
||||
expect((yield* generate).text).toBe("third")
|
||||
expect((yield* generate).text).toBe("fourth")
|
||||
expect(llm.requests).toEqual([request, request, request, request])
|
||||
}),
|
||||
)
|
||||
|
||||
legacy.effect("keeps module-level controls and clientLayer on the same backing state", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const requests = llm.requests
|
||||
yield* TestLLM.push(TestLLM.text("queued", "queued"))
|
||||
yield* TestLLM.always(TestLLM.text("fallback", "fallback"))
|
||||
expect((yield* LLMClient.generate(request).pipe(Effect.provide(TestLLM.clientLayer))).text).toBe("queued")
|
||||
yield* TestLLM.wait(1)
|
||||
expect(requests).toEqual([request])
|
||||
requests.length = 0
|
||||
expect((yield* llm.client.generate(request)).text).toBe("fallback")
|
||||
expect(llm.requests).toBe(requests)
|
||||
expect(requests).toEqual([request])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const client = yield* LLMClient.Service
|
||||
expect(client).toBe(llm)
|
||||
const before = yield* llm.requests()
|
||||
yield* llm.push(TestLLM.text("hello", "answer"))
|
||||
const generate = client.generate(request)
|
||||
client.stream(request)
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
|
||||
expect((yield* generate).text).toBe("hello")
|
||||
expect(before).toEqual([])
|
||||
expect(yield* llm.requests()).toEqual([request])
|
||||
expect(yield* llm.requests()).not.toBe(yield* llm.requests())
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prioritizes queued replies over request-dependent and constant fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const served: LLMRequest[] = []
|
||||
yield* llm.always(TestLLM.text("old fallback", "old"))
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
yield* llm.serve((request) => {
|
||||
served.push(request)
|
||||
return TestLLM.text(request.promptCacheKey ?? "default", "served")
|
||||
})
|
||||
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("first")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("second")
|
||||
expect(served).toEqual([])
|
||||
const selected = LLMRequest.update(request, { promptCacheKey: "selected" })
|
||||
expect((yield* LLMClient.generate(selected)).text).toBe("selected")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("default")
|
||||
expect(served).toEqual([selected, request])
|
||||
|
||||
yield* llm.push(TestLLM.text("queued again", "queued"))
|
||||
yield* llm.always(TestLLM.text("constant", "constant"))
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("queued again")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("constant")
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("constant")
|
||||
expect(served).toEqual([selected, request])
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
TestLLM.testLayer({
|
||||
transformRequest: (request) => LLMRequest.update(request, { promptCacheKey: "observation" }),
|
||||
}),
|
||||
).effect("transforms observations without changing the request passed to the responder", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.serve((input) => {
|
||||
expect(input).toBe(request)
|
||||
return TestLLM.text("original", "answer")
|
||||
})
|
||||
const generate = llm.generate(request)
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
expect((yield* generate).text).toBe("original")
|
||||
expect(yield* llm.requests()).toEqual([LLMRequest.update(request, { promptCacheKey: "observation" })])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("broadcasts request-arrival waits and satisfies waits registered afterward", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
const first = yield* llm.wait(2).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const second = yield* llm.wait(2).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* llm.generate(request)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* llm.generate(request)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
yield* llm.wait(2)
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
;(["queued", "served"] as const).forEach((mode) => {
|
||||
it.effect(`assigns ${mode} replies before resuming request-arrival continuations`, () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const responses = [TestLLM.text("first", "first"), TestLLM.text("second", "second")]
|
||||
yield* mode === "queued" ? llm.push(...responses) : llm.serve(() => responses.shift() ?? [])
|
||||
const later = yield* llm
|
||||
.wait(1)
|
||||
.pipe(Effect.andThen(llm.generate(request)), Effect.forkChild({ startImmediately: true }))
|
||||
|
||||
expect((yield* llm.generate(request)).text).toBe("first")
|
||||
expect((yield* Fiber.join(later)).text).toBe("second")
|
||||
expect(yield* llm.requests()).toEqual([request, request])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("notifies arrival waiters even when the responder defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const defect = new Error("Broken fixture responder")
|
||||
yield* llm.serve(() => {
|
||||
throw defect
|
||||
})
|
||||
const waiter = yield* llm.wait(1).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
yield* Fiber.join(waiter)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds independent state even when the same layer is provided concurrently", () => {
|
||||
const layer = TestLLM.testLayer()
|
||||
const run = Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
expect(yield* llm.requests()).toEqual([])
|
||||
yield* llm.push(TestLLM.text("one", "answer"))
|
||||
expect((yield* LLMClient.generate(request)).text).toBe("one")
|
||||
return yield* llm.requests()
|
||||
}).pipe(Effect.provide(layer))
|
||||
return Effect.gen(function* () {
|
||||
expect(yield* Effect.all([run, run], { concurrency: "unbounded" })).toEqual([[request], [request]])
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("counts concurrent starts on one gate without serializing their response assignment", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.push(TestLLM.text("first", "first"), TestLLM.text("second", "second"))
|
||||
const generate = llm.generate(request)
|
||||
const gate = yield* llm.gate()
|
||||
const first = yield* generate.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
const second = yield* generate.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* llm.wait(2)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* gate.release
|
||||
expect((yield* Fiber.join(first)).text).toBe("first")
|
||||
expect((yield* Fiber.join(second)).text).toBe("second")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not clear a replacement gate when the previous gate is released", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
const previous = yield* llm.gate()
|
||||
const first = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* previous.started
|
||||
const next = yield* llm.gate()
|
||||
yield* previous.release
|
||||
yield* Fiber.join(first)
|
||||
|
||||
const second = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* next.started
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
yield* next.release
|
||||
yield* Fiber.join(second)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("releases a gate when its deliberately narrower scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.always(TestLLM.stop())
|
||||
// Only the gate is scoped here; its release must happen before the test ends.
|
||||
const run = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* llm.gate()
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
return run
|
||||
}),
|
||||
)
|
||||
yield* Fiber.join(run)
|
||||
yield* llm.generate(request)
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps an executed response consumed after interruption and permits later requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* llm.push(TestLLM.text("interrupted", "first"), TestLLM.text("next", "second"))
|
||||
const gate = yield* llm.gate()
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* Fiber.interrupt(run)
|
||||
yield* gate.release
|
||||
expect((yield* llm.generate(request)).text).toBe("next")
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("consumes a supplied stream's post-finish tail and runs its finalizer", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const tail = yield* Latch.make()
|
||||
const release = yield* Latch.make()
|
||||
const finalized = yield* Latch.make()
|
||||
yield* llm.push(
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => finalized.open)
|
||||
return Stream.fromIterable(TestLLM.text("complete", "answer")).pipe(
|
||||
Stream.concat(Stream.fromEffect(tail.open.pipe(Effect.andThen(release.await))).pipe(Stream.drain)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const run = yield* llm.generate(request).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* tail.await
|
||||
expect(run.pollUnsafe()).toBeUndefined()
|
||||
yield* release.open
|
||||
expect((yield* Fiber.join(run)).text).toBe("complete")
|
||||
yield* finalized.await
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves irregular events, ordinary EOF, typed failures, and responder defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const events = [LLMEvent.textDelta({ id: "without-start", text: "partial" })]
|
||||
yield* llm.push(events, [])
|
||||
expect(yield* Stream.runCollect(llm.stream(request))).toEqual(events)
|
||||
expect(yield* Stream.runCollect(llm.stream(request))).toEqual([])
|
||||
|
||||
const failure = new AIError({ reason: new RateLimitError({ message: "Try later" }) })
|
||||
const observed: LLMEvent[] = []
|
||||
yield* llm.serve(() => TestLLM.failAfter(failure, ...events))
|
||||
expect(
|
||||
yield* llm.stream(request).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => observed.push(event))),
|
||||
Effect.flip,
|
||||
),
|
||||
).toBe(failure)
|
||||
expect(observed).toEqual(events)
|
||||
expect(yield* llm.generate(request).pipe(Effect.flip)).toBe(failure)
|
||||
|
||||
const defect = new Error("Broken fixture responder")
|
||||
yield* llm.serve(() => {
|
||||
throw defect
|
||||
})
|
||||
expect(yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
yield* llm.push(TestLLM.text("recovered", "answer"))
|
||||
expect((yield* llm.generate(request)).text).toBe("recovered")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defects on unexpected requests instead of waiting for a late script", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
const defect = yield* llm.generate(request).pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
if (!(defect instanceof Error)) return
|
||||
expect(defect.message).toBe("TestLLM has no response for request 1")
|
||||
expect(yield* llm.requests()).toEqual([request])
|
||||
yield* llm.push(TestLLM.stop())
|
||||
yield* llm.generate(request)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -5,5 +5,5 @@
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts"]
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
|
||||
}
|
||||
|
||||
@@ -84,15 +84,12 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
packages: {
|
||||
resolve: (spec, install = true, revision) =>
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(
|
||||
install
|
||||
? npm.add(spec, { subpaths: ["tui"], revision })
|
||||
: npm.resolve(spec, { subpaths: ["tui"], revision }),
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
),
|
||||
check: (spec) => runPromise(npm.check(spec)),
|
||||
update: (spec) => runPromise(npm.update(spec, { subpaths: ["tui"] })),
|
||||
reload: (spec, options) => runPromise(npm.reload(spec, { subpaths: ["tui"], ...options })),
|
||||
},
|
||||
environment: requestedServer === undefined ? Env.session() : undefined,
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect, FileSystem, Layer, Stream } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import assert from "node:assert/strict"
|
||||
import path from "node:path"
|
||||
import { Updater } from "../updater"
|
||||
|
||||
const latest = { version: "0.0.0-beta-17498" }
|
||||
const installs: string[] = []
|
||||
|
||||
// This fixture runs in its own process; no real update requests or installs occur.
|
||||
globalThis.fetch = Object.assign(async () => Response.json(latest), { preconnect() {} })
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
|
||||
const dependencies = Layer.mergeAll(
|
||||
Layer.succeed(FileSystem.FileSystem, fs),
|
||||
Layer.succeed(
|
||||
Global.Service,
|
||||
Global.make({ home: directory, config: directory, cache: path.join(directory, "cache") }),
|
||||
),
|
||||
Layer.succeed(AppProcess.Service, {
|
||||
...ChildProcessSpawner.make(() => Effect.die("Unexpected process spawn")),
|
||||
runStream: () => Stream.die("Unexpected streaming process"),
|
||||
run: (command) => {
|
||||
assert.equal(command._tag, "StandardCommand")
|
||||
if (command.command === "npm" && command.args[0] === "install") {
|
||||
assert.ok(command.args.includes("--global"))
|
||||
installs.push(command.args.at(-1)!)
|
||||
}
|
||||
return Effect.succeed({
|
||||
command: command.command,
|
||||
exitCode: 0,
|
||||
stdout: Buffer.from(command.command === "npm" ? "@opencode-ai/cli" : ""),
|
||||
stderr: Buffer.alloc(0),
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check()
|
||||
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
|
||||
yield* updater.check()
|
||||
yield* updater.check()
|
||||
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
|
||||
latest.version = "0.0.0-beta-17499"
|
||||
yield* updater.check()
|
||||
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498", "@opencode-ai/cli@0.0.0-beta-17499"])
|
||||
}).pipe(Effect.provide(Updater.layer.pipe(Layer.provide(dependencies))))
|
||||
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
@@ -5,9 +5,9 @@ const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy, installed = current): Action {
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === false) return "none"
|
||||
const currentVersion = parseReleaseVersion(installed)
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { action } from "./updater-action"
|
||||
import { decodePolicy } from "./updater"
|
||||
|
||||
describe("updater", () => {
|
||||
test("remembers successful installs across checks and accepts the next release", async () => {
|
||||
// Isolate compiled version constants and the update endpoint from other tests.
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"--define",
|
||||
'OPENCODE_VERSION="0.0.0-next-16473"',
|
||||
"--define",
|
||||
'OPENCODE_CHANNEL="beta"',
|
||||
path.join(import.meta.dir, "fixtures/updater.ts"),
|
||||
],
|
||||
{ env: { ...process.env, OPENCODE_DISABLE_AUTOUPDATE: "" }, stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
expect(code, stdout + stderr).toBe(0)
|
||||
})
|
||||
|
||||
test("reads autoupdate from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
|
||||
@@ -54,14 +32,6 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.3", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("skips an installed update but still accepts the next release", () => {
|
||||
const current = "0.0.0-next-16473"
|
||||
const installed = "0.0.0-beta-17498"
|
||||
expect(action(current, installed, true)).toBe("upgrade")
|
||||
expect(action(current, installed, true, installed)).toBe("none")
|
||||
expect(action(current, "0.0.0-beta-17499", true, installed)).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
@@ -38,7 +38,6 @@ export const layer = Layer.effect(
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const installed = yield* Ref.make(OPENCODE_VERSION)
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
@@ -113,20 +112,18 @@ export const layer = Layer.effect(
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
|
||||
const target = `${packageName}@${version}`
|
||||
const commands: Record<Exclude<Method, "bun" | "curl" | "npm">, string[]> = {
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
if (method === "bun" || method === "npm") {
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const command =
|
||||
method === "bun"
|
||||
? ["bun", "install", "--global", "--trust", "--cache-dir", cache, target]
|
||||
: ["npm", "install", "--global", "--cache", cache, target]
|
||||
return yield* run(command, "5 minutes")
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
@@ -159,19 +156,18 @@ export const layer = Layer.effect(
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const version = yield* latest()
|
||||
const current = yield* Ref.get(installed)
|
||||
yield* Effect.logInfo("update check", {
|
||||
current,
|
||||
current: OPENCODE_VERSION,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(OPENCODE_VERSION, version, policy, current)
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
if (next === "notify") return yield* Effect.logInfo("OpenCode update available", { current, latest: version })
|
||||
if (next === "notify")
|
||||
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
const detected = yield* method()
|
||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* upgrade(detected, version)
|
||||
yield* Ref.set(installed, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
})
|
||||
},
|
||||
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
|
||||
|
||||
@@ -88,32 +88,8 @@ export type PluginListInput = {
|
||||
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target: string
|
||||
}
|
||||
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: Plugin.PackageStatus }
|
||||
export type PluginCheckOperation<E = never> = (input: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target: string
|
||||
}
|
||||
export type PluginUpdateOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
|
||||
|
||||
export type PluginReloadInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target: string
|
||||
}
|
||||
export type PluginReloadOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginReloadOperation<E = never> = (input: PluginReloadInput) => Effect.Effect<PluginReloadOutput, E>
|
||||
|
||||
export interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
readonly update: PluginUpdateOperation<E>
|
||||
readonly reload: PluginReloadOperation<E>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
@@ -1663,6 +1639,23 @@ export interface PtyApi<E = never> {
|
||||
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadInput = { readonly sessionID: Session.ID; readonly lines?: number | undefined }
|
||||
export type ExperimentalPersistentPtyReadOutput = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly title: string
|
||||
readonly cwd: string
|
||||
readonly foregroundProcess: string | null
|
||||
readonly screen: {
|
||||
readonly text: string
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
} | null
|
||||
export type ExperimentalPersistentPtyReadOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyReadInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyReadOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID }
|
||||
export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{
|
||||
readonly id: Pty.ID
|
||||
@@ -1811,6 +1804,7 @@ export type ExperimentalPersistentPtyConnectTokenOperation<E = never> = (
|
||||
|
||||
export interface ExperimentalApi<E = never> {
|
||||
readonly persistentPty: {
|
||||
readonly read: ExperimentalPersistentPtyReadOperation<E>
|
||||
readonly list: ExperimentalPersistentPtyListOperation<E>
|
||||
readonly create: ExperimentalPersistentPtyCreateOperation<E>
|
||||
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
|
||||
|
||||
@@ -15,12 +15,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginReloadInput,
|
||||
PluginReloadOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -204,6 +198,8 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyReadInput,
|
||||
ExperimentalPersistentPtyReadOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
@@ -322,33 +318,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
|
||||
preserveEffect<PluginUpdateOutput>()(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointPluginReload = (raw: RawClient["server.plugin"]) => (input: PluginReloadInput) =>
|
||||
preserveEffect<PluginReloadOutput>()(
|
||||
raw["plugin.reload"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
reload: EndpointPluginReload(raw),
|
||||
})
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
|
||||
|
||||
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
|
||||
preserveEffect<SessionListOutput>()(
|
||||
@@ -1266,6 +1236,15 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
|
||||
connect: { token: EndpointPtyConnectToken(raw) },
|
||||
})
|
||||
|
||||
const EndpointExperimentalPersistentPtyRead =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyReadInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyReadOutput>()(
|
||||
raw["persistentPty.read"]({ params: { sessionID: input["sessionID"] }, query: { lines: input["lines"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyList =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyListInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyListOutput>()(
|
||||
@@ -1354,6 +1333,7 @@ const EndpointExperimentalPersistentPtyConnectToken =
|
||||
|
||||
const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
|
||||
persistentPty: {
|
||||
read: EndpointExperimentalPersistentPtyRead(raw),
|
||||
list: EndpointExperimentalPersistentPtyList(raw),
|
||||
create: EndpointExperimentalPersistentPtyCreate(raw),
|
||||
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
|
||||
|
||||
@@ -9,12 +9,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginReloadInput,
|
||||
PluginReloadOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -200,6 +194,8 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyReadInput,
|
||||
ExperimentalPersistentPtyReadOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
@@ -466,45 +462,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/check`,
|
||||
query: { location: input["location"] },
|
||||
body: { target: input["target"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/update`,
|
||||
query: { location: input["location"] },
|
||||
body: { target: input["target"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
reload: (input: PluginReloadInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginReloadOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/reload`,
|
||||
query: { location: input["location"] },
|
||||
body: { target: input["target"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
session: {
|
||||
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
|
||||
@@ -1729,6 +1686,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
experimental: {
|
||||
persistentPty: {
|
||||
read: (input: ExperimentalPersistentPtyReadInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyReadOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal/read`,
|
||||
query: { lines: input["lines"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
list: (input: ExperimentalPersistentPtyListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyListOutput }>(
|
||||
{
|
||||
|
||||
@@ -16,8 +16,6 @@ export type PluginSource =
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
export type PluginPackageStatus = { installed?: string; available?: string; mutable: boolean }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
export type MoneyUSD = number
|
||||
@@ -380,6 +378,14 @@ export type SessionStatus =
|
||||
|
||||
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
|
||||
|
||||
export type PersistentPtyReadResult = {
|
||||
ptyID: string
|
||||
title: string
|
||||
cwd: string
|
||||
foregroundProcess: string | null
|
||||
screen: { text: string; cols: number; rows: number; cursor: { x: number; y: number } }
|
||||
}
|
||||
|
||||
export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number }
|
||||
|
||||
export type ShellInfo1 = {
|
||||
@@ -435,24 +441,8 @@ export type ProviderRequest = {
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type PluginInfo =
|
||||
| {
|
||||
id: string
|
||||
source: PluginSource
|
||||
status: "active"
|
||||
tui: boolean
|
||||
revision?: string
|
||||
generation?: string
|
||||
error?: string
|
||||
}
|
||||
| {
|
||||
id?: string
|
||||
source: PluginSource
|
||||
status: "failed"
|
||||
error: string
|
||||
tui: boolean
|
||||
revision?: string
|
||||
generation?: string
|
||||
}
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
|
||||
export type SessionMessageLocationSwitched = {
|
||||
id: string
|
||||
@@ -2348,10 +2338,6 @@ export type AgentNotFoundError = {
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type PluginOperationError = { readonly _tag: "PluginOperationError"; readonly message: string }
|
||||
export const isPluginOperationError = (value: unknown): value is PluginOperationError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginOperationError"
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
@@ -2562,42 +2548,6 @@ export type PluginListOutput = {
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target: { readonly target: string }["target"]
|
||||
}
|
||||
|
||||
export type PluginCheckOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: PluginPackageStatus
|
||||
}
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target: { readonly target: string }["target"]
|
||||
}
|
||||
|
||||
export type PluginUpdateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginReloadInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target: { readonly target: string }["target"]
|
||||
}
|
||||
|
||||
export type PluginReloadOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
@@ -5827,6 +5777,13 @@ export type PtyConnectTokenOutput = {
|
||||
data: PtyTicketConnectToken
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly lines?: { readonly lines?: number | undefined }["lines"]
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadOutput = { data: PersistentPtyReadResult | null }["data"]
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
|
||||
|
||||
@@ -215,8 +215,10 @@ export function createData(config: CreateDataInput) {
|
||||
)
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
const sync = createSync()
|
||||
let activeUpdates: Map<string, DataSessionStatus | undefined> | undefined
|
||||
|
||||
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||
activeUpdates?.set(sessionID, status)
|
||||
setStore("session", "active", sessionID, status)
|
||||
}
|
||||
|
||||
@@ -473,6 +475,7 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
activeUpdates?.set(sessionID, undefined)
|
||||
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
|
||||
messageIndex.delete(sessionID)
|
||||
sync.invalidate(`session:${sessionID}`)
|
||||
@@ -504,17 +507,25 @@ export function createData(config: CreateDataInput) {
|
||||
|
||||
function handleEvent(event: OpenCodeEvent) {
|
||||
switch (event.type) {
|
||||
case "server.connected":
|
||||
case "server.connected": {
|
||||
const updates = new Map<string, DataSessionStatus | undefined>()
|
||||
activeUpdates = updates
|
||||
void api()
|
||||
.session.active()
|
||||
.then((active) => {
|
||||
setStore(
|
||||
"session",
|
||||
"active",
|
||||
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
|
||||
)
|
||||
if (activeUpdates !== updates) return
|
||||
// Lifecycle events received during hydration supersede the snapshot.
|
||||
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
|
||||
updates.forEach((status, id) => {
|
||||
if (status === undefined) return snapshot.delete(id)
|
||||
snapshot.set(id, status)
|
||||
})
|
||||
activeUpdates = undefined
|
||||
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
|
||||
})
|
||||
.catch(() => {
|
||||
if (activeUpdates === updates) activeUpdates = undefined
|
||||
})
|
||||
.catch(() => undefined)
|
||||
void api()
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
@@ -525,6 +536,7 @@ export function createData(config: CreateDataInput) {
|
||||
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
|
||||
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
|
||||
return
|
||||
}
|
||||
case "project.updated":
|
||||
setStore("project", "info", event.data.id, reconcile(event.data))
|
||||
return
|
||||
|
||||
@@ -50,6 +50,8 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
|
||||
expect(client.experimental.persistentPty.read).toBeFunction()
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
|
||||
@@ -485,6 +485,102 @@ test("preserves assistant content replacement events across an active message re
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
"session.execution.succeeded",
|
||||
"session.execution.failed",
|
||||
"session.execution.interrupted",
|
||||
"session.execution.started",
|
||||
"session.deleted",
|
||||
] as const)("preserves %s activity when an older snapshot arrives", async (type) => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const setup = activityFixture(async () => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
return Response.json({
|
||||
data: {
|
||||
...(type === "session.execution.started" ? {} : { ses_refresh: { type: "running" } }),
|
||||
ses_hydrated: { type: "running" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (type !== "session.execution.started") setup.data.session.setStatus("ses_refresh", "running")
|
||||
setup.emit({ type: "server.connected", data: {} })
|
||||
await requested.promise
|
||||
setup.emit({
|
||||
id: "evt_activity",
|
||||
created: 2,
|
||||
type,
|
||||
durable: { aggregateID: "ses_refresh", seq: 2, version: 1 },
|
||||
data: { sessionID: "ses_refresh", reason: "user" },
|
||||
})
|
||||
expect(setup.data.session.status("ses_refresh")).toBe(type === "session.execution.started" ? "running" : "idle")
|
||||
release.resolve()
|
||||
await wait(() => setup.data.session.status("ses_hydrated") === "running")
|
||||
expect(setup.data.session.status("ses_refresh")).toBe(type === "session.execution.started" ? "running" : "idle")
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores activity snapshots from an older connection", async () => {
|
||||
const reads: ReturnType<typeof Promise.withResolvers<Response>>[] = []
|
||||
const setup = activityFixture(() => {
|
||||
const read = Promise.withResolvers<Response>()
|
||||
reads.push(read)
|
||||
return read.promise
|
||||
})
|
||||
|
||||
try {
|
||||
setup.emit({ type: "server.connected", data: {} })
|
||||
await wait(() => reads.length === 1)
|
||||
setup.emit({ type: "server.connected", data: {} })
|
||||
await wait(() => reads.length === 2)
|
||||
reads[1]?.resolve(Response.json({ data: { ses_new: { type: "running" } } }))
|
||||
await wait(() => setup.data.session.status("ses_new") === "running")
|
||||
reads[0]?.resolve(Response.json({ data: { ses_old: { type: "running" } } }))
|
||||
await Bun.sleep(20)
|
||||
expect(setup.data.session.status("ses_new")).toBe("running")
|
||||
expect(setup.data.session.status("ses_old")).toBe("idle")
|
||||
} finally {
|
||||
reads.forEach((read) => read.resolve(Response.json({ data: {} })))
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function activityFixture(read: () => Response | Promise<Response>) {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/api/session/active") return read()
|
||||
if (path === "/api/project") return Response.json([])
|
||||
if (path === "/api/location") return Response.json({ directory: "/project" })
|
||||
return Response.json({ location: { directory: "/project" }, data: { branch: "main" } })
|
||||
},
|
||||
})
|
||||
return createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
|
||||
dispose,
|
||||
}))
|
||||
}
|
||||
|
||||
async function wait(check: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!check()) {
|
||||
|
||||
@@ -612,12 +612,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
||||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () => language.doStream(options),
|
||||
catch: llmError,
|
||||
catch: (error) => llmError(error, "request"),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
Stream.fromReadableStream({
|
||||
evaluate: () => result.stream,
|
||||
onError: llmError,
|
||||
onError: (error) => llmError(error, "read"),
|
||||
}).pipe(
|
||||
Stream.mapEffect((event) => streamPartEvents(state, event)),
|
||||
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||
@@ -744,7 +744,7 @@ function streamPartEvents(
|
||||
}),
|
||||
])
|
||||
case "error":
|
||||
return Effect.fail(llmError(event.error))
|
||||
return Effect.fail(llmError(event.error, "read"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,9 +794,20 @@ function messageValue(input: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function llmError(error: unknown) {
|
||||
function llmError(error: unknown, operation: "request" | "read") {
|
||||
if (error instanceof AIError) return error
|
||||
if (APICallError.isInstance(error)) return apiCallError(error)
|
||||
const network = networkFailure(error)
|
||||
if (network)
|
||||
return new AIError({
|
||||
reason: new TransportError({
|
||||
message: network.message.trim() === "" ? unknownErrorMessage(error) : network.message,
|
||||
cause: error,
|
||||
transport: "http",
|
||||
operation,
|
||||
code: network.code,
|
||||
}),
|
||||
})
|
||||
return new AIError({
|
||||
reason: new UnknownProviderError({
|
||||
message: unknownErrorMessage(error),
|
||||
@@ -806,6 +817,50 @@ function llmError(error: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
// Runtime-generated network failure shapes. The codes mirror the AI SDK's own
|
||||
// Bun network error list in handleFetchError; the messages are undici's fetch
|
||||
// TypeError and stream termination strings plus our SSE chunk timeout error.
|
||||
// Unrecognized shapes still retry via the UnknownProvider default; this match
|
||||
// only adds transport semantics (continuation eligibility, display).
|
||||
const NETWORK_ERROR_CODES = new Set([
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
"ETIMEDOUT",
|
||||
"EPIPE",
|
||||
"ConnectionRefused",
|
||||
"ConnectionClosed",
|
||||
"FailedToOpenSocket",
|
||||
])
|
||||
const NETWORK_ERROR_MESSAGES = new Set([
|
||||
"fetch failed",
|
||||
"failed to fetch",
|
||||
"terminated",
|
||||
"other side closed",
|
||||
"sse read timed out",
|
||||
])
|
||||
|
||||
const NativeErrorShape = Schema.Struct({
|
||||
message: Schema.String,
|
||||
code: Schema.optionalKey(Schema.String),
|
||||
cause: Schema.optionalKey(Schema.Unknown),
|
||||
})
|
||||
const decodeNativeErrorShape = Schema.decodeUnknownOption(NativeErrorShape)
|
||||
|
||||
function networkFailure(error: unknown, depth = 0): { message: string; code?: string } | undefined {
|
||||
if (depth > 4) return undefined
|
||||
const shape = Option.getOrUndefined(decodeNativeErrorShape(error))
|
||||
if (!shape) return undefined
|
||||
// Prefer the deepest match: wrappers like undici's "fetch failed" TypeError
|
||||
// carry the specific network code on their cause.
|
||||
const cause = networkFailure(shape.cause, depth + 1)
|
||||
if (cause) return cause
|
||||
if (shape.code !== undefined && (NETWORK_ERROR_CODES.has(shape.code) || shape.code.startsWith("UND_ERR")))
|
||||
return { message: shape.message, code: shape.code }
|
||||
if (NETWORK_ERROR_MESSAGES.has(shape.message.trim().toLowerCase()))
|
||||
return { message: shape.message, code: shape.code }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function apiCallError(error: APICallError) {
|
||||
const failure = RequestExecutor.httpFailure({
|
||||
message: providerErrorMessage(error),
|
||||
|
||||
+103
-32
@@ -1,6 +1,6 @@
|
||||
export * as Bus from "./bus.js"
|
||||
|
||||
import { Cause, Clock, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Cause, Clock, Context, Effect, Layer, Option, PubSub, Schema, type Scope, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
|
||||
@@ -125,6 +125,8 @@ export interface Subscribe {
|
||||
* With an ambient Location, delivery is restricted to that Location and global
|
||||
* events. Unlocated Session events use the Session's owner at publication time.
|
||||
* Session moves reach both the old and new Location, without changing the event.
|
||||
* Captured instance views restrict instance-local ephemerals to their private
|
||||
* owner, unless published with `global: true`.
|
||||
*/
|
||||
(): Stream.Stream<Event.Payload>
|
||||
<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
|
||||
@@ -146,6 +148,8 @@ export interface Interface {
|
||||
events: I,
|
||||
) => Effect.Effect<PublishResult<I>>
|
||||
readonly subscribe: Subscribe
|
||||
/** Acquire a live Session subscription immediately, retained until the caller's Scope closes. */
|
||||
readonly observe: (sessionID: SessionID) => Effect.Effect<Stream.Stream<SessionEvent.Event>, never, Scope.Scope>
|
||||
/**
|
||||
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
|
||||
* inherited prefix before their first child-authored event. `follow: false`
|
||||
@@ -171,6 +175,33 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
|
||||
export const PrivateOwner = Context.Reference<symbol | undefined>("@opencode/Bus/PrivateOwner", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
/** Bind instance-local ephemeral audiences without replacing the shared durable authority. */
|
||||
export function capture(bus: Interface, owner: symbol): Interface {
|
||||
function subscribe(): Stream.Stream<Event.Payload>
|
||||
function subscribe<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
|
||||
function subscribe<const D extends readonly [Event.Definition, ...Event.Definition[]]>(
|
||||
definitions: D,
|
||||
): Stream.Stream<SubscribePayload<D>>
|
||||
function subscribe(
|
||||
input?: Event.Definition | readonly [Event.Definition, ...Event.Definition[]],
|
||||
): Stream.Stream<Event.Payload> {
|
||||
const stream =
|
||||
input === undefined ? bus.subscribe() : isDefinition(input) ? bus.subscribe(input) : bus.subscribe(input)
|
||||
return stream.pipe(Stream.provideService(PrivateOwner, owner))
|
||||
}
|
||||
|
||||
return {
|
||||
...bus,
|
||||
publish: (definition, data, options) =>
|
||||
bus.publish(definition, data, options).pipe(Effect.provideService(PrivateOwner, owner)),
|
||||
subscribe,
|
||||
}
|
||||
}
|
||||
|
||||
interface Options {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */
|
||||
@@ -205,6 +236,36 @@ export function configured(options?: Options) {
|
||||
// Keep routing separate from the public event, and retain its snapshot
|
||||
// while a slow subscriber drains events queued before a move or deletion.
|
||||
const routes = new WeakMap<Event.Payload, readonly Location.Ref[]>()
|
||||
const audiences = new WeakMap<Event.Payload, symbol | null>()
|
||||
const localTypes = new Set([
|
||||
"mcp.tools.changed",
|
||||
"mcp.prompts.changed",
|
||||
"mcp.resources.changed",
|
||||
"mcp.status.changed",
|
||||
"plugin.added",
|
||||
"plugin.updated",
|
||||
"config.updated",
|
||||
"agent.updated",
|
||||
"catalog.updated",
|
||||
"integration.updated",
|
||||
"command.updated",
|
||||
"reference.updated",
|
||||
"skill.updated",
|
||||
"websearch.updated",
|
||||
"instruction-discovery.updated",
|
||||
"permission.asked",
|
||||
"permission.replied",
|
||||
"form.created",
|
||||
"form.replied",
|
||||
"form.cancelled",
|
||||
"pty.created",
|
||||
"pty.updated",
|
||||
"pty.exited",
|
||||
"pty.deleted",
|
||||
"shell.created",
|
||||
"shell.exited",
|
||||
"shell.deleted",
|
||||
])
|
||||
|
||||
const isSessionEvent = (event: Event.Payload): event is SessionEvent.Event =>
|
||||
Object.hasOwn(SessionEvent.All.cases, event.type)
|
||||
@@ -489,7 +550,7 @@ export function configured(options?: Options) {
|
||||
})
|
||||
}
|
||||
|
||||
const observe = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect<void>) =>
|
||||
const observeListener = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect<void>) =>
|
||||
Effect.suspend(() => observer(event)).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
@@ -501,7 +562,7 @@ export function configured(options?: Options) {
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
listeners,
|
||||
(listener) => (isolateListeners ? observe(event, listener) : listener(event)),
|
||||
(listener) => (isolateListeners ? observeListener(event, listener) : listener(event)),
|
||||
{ discard: true },
|
||||
)
|
||||
const typed = pubsub.typed.get(event.type)
|
||||
@@ -519,18 +580,19 @@ export function configured(options?: Options) {
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined))
|
||||
return yield* publishEvent(
|
||||
definition,
|
||||
{
|
||||
id: options?.id ?? Event.ID.create(),
|
||||
created: yield* Clock.currentTimeMillis,
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Event.Payload<D>,
|
||||
options?.commit,
|
||||
)
|
||||
const event = {
|
||||
id: options?.id ?? Event.ID.create(),
|
||||
created: yield* Clock.currentTimeMillis,
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Event.Payload<D>
|
||||
if (!definition.durable && localTypes.has(definition.type)) {
|
||||
const owner = options?.global ? null : yield* PrivateOwner
|
||||
if (owner !== undefined) audiences.set(event as Event.Payload, owner)
|
||||
}
|
||||
return yield* publishEvent(definition, event, options?.commit)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -731,24 +793,24 @@ export function configured(options?: Options) {
|
||||
|
||||
const local = <A extends Event.Payload>(stream: Stream.Stream<A>) =>
|
||||
Stream.unwrap(
|
||||
Effect.serviceOption(Location.Service).pipe(
|
||||
Effect.map((location) =>
|
||||
Option.match(location, {
|
||||
onNone: () => stream,
|
||||
onSome: (location) => {
|
||||
const matches = (ref: Location.Ref) =>
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
return stream.pipe(
|
||||
Stream.filter((event) => {
|
||||
const refs = routes.get(event)
|
||||
if (refs) return refs.some(matches)
|
||||
return !event.location || matches(event.location)
|
||||
}),
|
||||
)
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
const owner = yield* PrivateOwner
|
||||
const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const matches = (ref: Location.Ref) =>
|
||||
ref.directory === location?.directory && ref.workspaceID === location?.workspaceID
|
||||
return stream.pipe(
|
||||
Stream.filter((event) => {
|
||||
if (!event.durable && localTypes.has(event.type)) {
|
||||
const audience = audiences.get(event)
|
||||
if (audience !== null && audience !== owner) return false
|
||||
}
|
||||
if (!location) return true
|
||||
const refs = routes.get(event)
|
||||
if (refs) return refs.some(matches)
|
||||
return !event.location || matches(event.location)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function subscribe(): Stream.Stream<Event.Payload>
|
||||
@@ -767,6 +829,14 @@ export function configured(options?: Options) {
|
||||
|
||||
const streamLive = (): Stream.Stream<Event.Payload> => local(Stream.fromPubSub(pubsub.live))
|
||||
|
||||
const observe = Effect.fn("Bus.observe")(function* (sessionID: SessionID) {
|
||||
const subscription = yield* PubSub.subscribe(pubsub.live)
|
||||
return Stream.fromSubscription(subscription).pipe(
|
||||
Stream.filter(isSessionEvent),
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
)
|
||||
})
|
||||
|
||||
const readAfter = (
|
||||
aggregateID: string,
|
||||
after: number,
|
||||
@@ -903,6 +973,7 @@ export function configured(options?: Options) {
|
||||
publish,
|
||||
publishAll,
|
||||
subscribe,
|
||||
observe,
|
||||
log,
|
||||
listen,
|
||||
project,
|
||||
|
||||
@@ -4,9 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
type Entry,
|
||||
Event,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Watcher } from "./filesystem/watcher.js"
|
||||
@@ -36,8 +33,6 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
||||
export interface Interface {
|
||||
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
||||
readonly entries: () => Effect.Effect<Entry[]>
|
||||
/** Updates the first file-backed configuration document. */
|
||||
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, UpdateError>
|
||||
/**
|
||||
* Streams raw filesystem updates under config roots. Config owns root
|
||||
* topology and watch reconciliation; domain owners filter this feed for the
|
||||
@@ -46,11 +41,6 @@ export interface Interface {
|
||||
readonly changes: () => Stream.Stream<Watcher.Update>
|
||||
}
|
||||
|
||||
export class UpdateError extends Schema.TaggedError<UpdateError>()("Config.UpdateError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
project: Schema.optional(Schema.Boolean),
|
||||
// false skips the global config dir, ~/.claude, and ~/.agents; wellknown,
|
||||
@@ -80,20 +70,6 @@ export const testLayer = (initial: Entry[] = []) =>
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const service = Test.of({
|
||||
entries: () => Ref.get(entries),
|
||||
update: (update) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(entries)
|
||||
const index = current.findIndex((entry) => entry.type === "document" && entry.path !== undefined)
|
||||
const entry = current[index]
|
||||
if (!entry || entry.type !== "document")
|
||||
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
||||
const info = yield* Effect.try({
|
||||
try: () => produce(entry.info, update),
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
yield* Ref.set(entries, current.with(index, new Document({ type: "document", path: entry.path, info })))
|
||||
return info
|
||||
}),
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
setEntries: (next) => Ref.set(entries, next),
|
||||
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
|
||||
@@ -400,54 +376,10 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
yield* reconcile(initial)
|
||||
|
||||
const update = Effect.fn("Config.update")((mutate: (draft: Draft<Info>) => void) =>
|
||||
reloadLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// TODO: Replace entry-order selection with an explicit config scope/target model.
|
||||
const document = configs.find((entry) => entry.type === "document" && entry.path !== undefined)
|
||||
if (!document || document.type !== "document" || !document.path)
|
||||
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
||||
const next = yield* Effect.try({
|
||||
try: () => produce(document.info, mutate),
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
const edits = changes(document.info, next)
|
||||
if (!edits.length) return document.info
|
||||
const text = yield* fs
|
||||
.readFileString(document.path)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new UpdateError({ message: `Failed to read config: ${document.path}`, cause }),
|
||||
),
|
||||
)
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
),
|
||||
text,
|
||||
)
|
||||
const info = yield* parseInfo(updated, document.path)
|
||||
if (!info)
|
||||
return yield* Effect.fail(new UpdateError({ message: `Invalid config update: ${document.path}` }))
|
||||
const temporary = document.path + ".tmp"
|
||||
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
|
||||
Effect.andThen(fs.rename(temporary, document.path)),
|
||||
Effect.mapError(
|
||||
(cause) => new UpdateError({ message: `Failed to write config: ${document.path}`, cause }),
|
||||
),
|
||||
)
|
||||
return info
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
entries: Effect.fnUntraced(function* () {
|
||||
return configs
|
||||
}),
|
||||
update,
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
})
|
||||
}),
|
||||
@@ -462,17 +394,3 @@ export function configured(options?: Options) {
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
|
||||
|
||||
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
|
||||
if (Object.is(before, after)) return []
|
||||
if (isRecord(before) && isRecord(after)) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
export * as ConfigFile from "./file.js"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import {
|
||||
applyEdits,
|
||||
createScanner,
|
||||
findNodeAtLocation,
|
||||
modify,
|
||||
parseTree,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from "jsonc-parser"
|
||||
|
||||
export class UpdateError extends Schema.TaggedError<UpdateError>()("ConfigFile.UpdateError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
const isJson = Schema.is(Schema.MutableJson)
|
||||
const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
/**
|
||||
* Edits an existing JSON(C) file using raw source values, not resolved Config.Info.
|
||||
* The synchronous callback mutates a source clone; its return value is ignored.
|
||||
* Validates JSON only; normalization and substitution remain the reader's job.
|
||||
* Does not discover files, start watchers, or refresh Config state.
|
||||
* Read-modify-write calls are serialized within this process.
|
||||
*/
|
||||
export const update = Effect.fn("ConfigFile.update")(
|
||||
function* (
|
||||
filepath: string,
|
||||
mutate: (draft: Schema.MutableJsonObject) => void,
|
||||
): Effect.fn.Return<Schema.JsonObject, UpdateError, FSUtil.Service> {
|
||||
const fs = yield* FSUtil.Service
|
||||
const text = yield* fs
|
||||
.readFileString(filepath)
|
||||
.pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause })))
|
||||
const errors: ParseError[] = []
|
||||
const current = parseSource(text, errors)
|
||||
if (errors.length || !isDocument(current))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` }))
|
||||
|
||||
const next = yield* Effect.try({
|
||||
try: () => {
|
||||
const draft = structuredClone(current)
|
||||
mutate(draft)
|
||||
return draft
|
||||
},
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
if (!isDocument(next))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` }))
|
||||
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return next
|
||||
const updated = yield* Effect.try({
|
||||
try: () => edits.reduce(patch, text),
|
||||
catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }),
|
||||
})
|
||||
// Duplicate keys can make parse choose the last value while modify edits the first.
|
||||
const written = parseSource(updated, errors)
|
||||
if (errors.length || !isDeepStrictEqual(written, next))
|
||||
return yield* Effect.fail(
|
||||
new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }),
|
||||
)
|
||||
const temporary = filepath + ".tmp"
|
||||
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
|
||||
Effect.andThen(fs.rename(temporary, filepath)),
|
||||
Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })),
|
||||
)
|
||||
return next
|
||||
},
|
||||
(effect) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
|
||||
|
||||
function parseSource(text: string, errors: ParseError[]) {
|
||||
const root = parseTree(text, errors, { allowTrailingComma: true })
|
||||
if (!root || errors.length) return undefined
|
||||
// parse() assigns onto {}, invoking the __proto__ setter instead of retaining
|
||||
// an own JSON key. Construct object entries from the AST without those setters.
|
||||
const value = (node: Node): unknown => {
|
||||
if (node.type === "array") return (node.children ?? []).map(value)
|
||||
if (node.type === "object")
|
||||
return Object.fromEntries(
|
||||
(node.children ?? []).map((property) => {
|
||||
const child = property.children?.[1]
|
||||
return [property.children?.[0]?.value, child && value(child)]
|
||||
}),
|
||||
)
|
||||
return node.value
|
||||
}
|
||||
return value(root)
|
||||
}
|
||||
|
||||
function patch(text: string, edit: Edit) {
|
||||
if (edit.value !== undefined)
|
||||
return applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
)
|
||||
|
||||
const tree = parseTree(text)
|
||||
const node = tree && findNodeAtLocation(tree, edit.path)
|
||||
if (!node) return text
|
||||
// jsonc-parser removes adjacent comments along with the separator. Remove only
|
||||
// the property/element itself and one comma, leaving surrounding comments intact.
|
||||
const target = node.parent?.type === "property" ? node.parent : node
|
||||
const siblings = target.parent?.children ?? []
|
||||
const previous = siblings[siblings.indexOf(target) - 1]
|
||||
const scanner = createScanner(text, true)
|
||||
scanner.setPosition(target.offset + target.length)
|
||||
scanner.scan()
|
||||
const following = text[scanner.getTokenOffset()] === ","
|
||||
if (!following && previous) {
|
||||
scanner.setPosition(previous.offset + previous.length)
|
||||
scanner.scan()
|
||||
}
|
||||
return applyEdits(text, [
|
||||
{ offset: target.offset, length: target.length, content: "" },
|
||||
...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []),
|
||||
])
|
||||
}
|
||||
|
||||
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
|
||||
if (isDeepStrictEqual(before, after)) return []
|
||||
if (Array.isArray(before) && Array.isArray(after)) {
|
||||
return [
|
||||
...after.flatMap((value, index) => changes(before[index], value, [...path, index])),
|
||||
// Remove from the end so earlier deletions cannot shift later paths.
|
||||
...before
|
||||
.slice(after.length)
|
||||
.map((_, index) => ({ path: [...path, after.length + index], value: undefined }))
|
||||
.toReversed(),
|
||||
]
|
||||
}
|
||||
if (isRecord(before) && isRecord(after)) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { App } from "./app.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { llmClient, webSocketConstructor } from "./effect/app-node-platform.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { FileMutation } from "./file-mutation.js"
|
||||
@@ -12,25 +23,34 @@ import { Formatter } from "./formatter.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Git } from "./git.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { ModelsDev } from "./models-dev.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { PermissionSaved } from "./permission/saved.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { PluginExecution } from "./plugin/execution.js"
|
||||
import { InstancePlugins } from "./plugin/instance.js"
|
||||
import { PluginRuntime } from "./plugin/runtime.js"
|
||||
import { SdkPlugins } from "./plugin/sdk.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Project } from "./project.js"
|
||||
import { ProjectMarkers } from "./project/markers.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { RepositoryCache } from "./repository-cache.js"
|
||||
import { RipgrepBinary } from "./ripgrep/binary.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
@@ -38,7 +58,10 @@ import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { SessionCompaction } from "./session/compaction.js"
|
||||
import { SessionTitle } from "./session/title.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import { SkillDiscovery } from "./skill/discovery.js"
|
||||
import { SkillInstructions } from "./skill/instructions.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { InstructionDiscovery } from "./instruction-discovery.js"
|
||||
@@ -51,6 +74,9 @@ import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { ToolOutput } from "./tool-output.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
import { Watcher } from "./filesystem/watcher.js"
|
||||
import { WellKnown } from "./wellknown.js"
|
||||
import { Workspace } from "./workspace.js"
|
||||
|
||||
export * as Instance from "./instance.js"
|
||||
|
||||
@@ -68,7 +94,6 @@ const nodes = [
|
||||
AISDK.node,
|
||||
Plugin.node,
|
||||
PluginHooks.node,
|
||||
PluginExecution.node,
|
||||
InstancePlugins.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
@@ -113,6 +138,46 @@ export const graph = LayerNode.group<typeof nodes>(nodes)
|
||||
export type Services = LayerNode.Output<typeof graph>
|
||||
export type Error = LayerNode.Error<typeof graph>
|
||||
|
||||
const globalNodes = [
|
||||
CrossSpawnSpawner.node,
|
||||
Workspace.node,
|
||||
Watcher.node,
|
||||
Bus.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
Credential.node,
|
||||
WellKnown.node,
|
||||
RepositoryCache.node,
|
||||
KV.node,
|
||||
AppProcess.node,
|
||||
Npm.node,
|
||||
App.node,
|
||||
llmClient,
|
||||
SessionStore.node,
|
||||
PermissionSaved.node,
|
||||
SdkPlugins.node,
|
||||
RipgrepBinary.node,
|
||||
httpClient,
|
||||
ProjectMarkers.node,
|
||||
ModelsDev.node,
|
||||
SessionEnvironment.node,
|
||||
Git.node,
|
||||
SkillDiscovery.node,
|
||||
Worktree.node,
|
||||
Database.node,
|
||||
webSocketConstructor,
|
||||
// Binding Location introduces Project even though the unbound graph does not.
|
||||
Project.node,
|
||||
] as const satisfies readonly Node.GlobalNode<unknown, unknown>[]
|
||||
|
||||
const globalJobs = new Map([Shell.cleanupNode, ToolOutput.cleanupNode].map((node) => [node.name, node] as const))
|
||||
|
||||
/** Build and configure this graph once in the host scope, before composing instances. */
|
||||
export const globalsGraph = LayerNode.group([...globalNodes, ...globalJobs.values()])
|
||||
|
||||
export type Globals = LayerNode.Output<typeof globalsGraph>
|
||||
export type GlobalsError = LayerNode.Error<typeof globalsGraph>
|
||||
|
||||
export interface Options {
|
||||
// Plugins this instance is born with; empty and absent are equivalent.
|
||||
readonly plugins?: InstancePlugins.List
|
||||
@@ -141,6 +206,66 @@ const vanillaReplacements: LayerNode.Replacements = [
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })],
|
||||
]
|
||||
|
||||
/**
|
||||
* Reuse already-acquired host infrastructure while giving each instance fresh
|
||||
* local services. Global replacements belong on globalsGraph; local replacements
|
||||
* and a closed per-instance PluginRuntime replacement belong here.
|
||||
*/
|
||||
export function compose<const Items extends LayerNode.Replacements = readonly []>(
|
||||
ref: Location.Ref,
|
||||
options: Omit<Options, "replacements"> & { readonly replacements?: LayerNode.ComposableReplacements<Items> } = {},
|
||||
): Layer.Layer<Services, Error | LayerNode.ReplacementError<Items>, Globals | LayerNode.ReplacementServices<Items>> {
|
||||
const startedAt = performance.now()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
...(options.discovery === false ? vanillaReplacements : []),
|
||||
...(options.replacements ?? []),
|
||||
[Location.node, Location.boundNode(ref, { discovery: options.discovery })],
|
||||
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
|
||||
]
|
||||
const hoisted = LayerNode.hoist(graph, Node.tags.values.global, replacements).hoisted
|
||||
// PluginRuntime itself is local to a direct instance, but a node replacement
|
||||
// can still depend on shared globals. Inspect those edges before binding them.
|
||||
const boundary = LayerNode.hoist(
|
||||
LayerNode.group(
|
||||
hoisted.dependencies.flatMap((node) => (node.name === PluginRuntime.node.name ? node.dependencies : [node])),
|
||||
),
|
||||
Node.tags.values.global,
|
||||
).hoisted
|
||||
const names = new Set(globalNodes.map((node) => node.name))
|
||||
const unsupported = boundary.dependencies.filter((node) => !names.has(node.name) && !globalJobs.has(node.name))
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(`Unsupported instance globals: ${unsupported.map((node) => node.name).join(", ")}`)
|
||||
}
|
||||
|
||||
return Layer.unwrap(
|
||||
Effect.map(Effect.context<Globals>(), (globals) => {
|
||||
const owner = Symbol()
|
||||
const captured = Layer.succeedContext(
|
||||
globals.pipe(
|
||||
Context.add(Bus.PrivateOwner, owner),
|
||||
Context.add(Bus.Service, Bus.capture(Context.get(globals, Bus.Service), owner)),
|
||||
),
|
||||
)
|
||||
const bindings: LayerNode.Replacements = boundary.dependencies.map((node) => [
|
||||
node,
|
||||
globalJobs.has(node.name) ? Layer.empty : captured,
|
||||
])
|
||||
// Compile the original graph with real closed implementations, not the
|
||||
// dependency-stripped hoist result that cannot honestly be a closed layer.
|
||||
return LayerNode.compile(graph, [...replacements, ...bindings]).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// One instance is one compiled, fresh copy of the graph standing on a directory.
|
||||
export function layer(ref: Location.Ref, options: Options = {}) {
|
||||
const startedAt = performance.now()
|
||||
|
||||
@@ -213,20 +213,11 @@ export const authorize = (input: {
|
||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
||||
})
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
yield* Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
|
||||
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
|
||||
if (result === "AUTHORIZED") {
|
||||
return {
|
||||
url: input.config.url,
|
||||
instructions: `Connected to ${input.name}.`,
|
||||
mode: "auto" as const,
|
||||
callback: finalize,
|
||||
}
|
||||
}
|
||||
if (!authorizationUrl)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ export const WireResponse = Schema.Union([
|
||||
cursor_x: Schema.Number,
|
||||
cursor_y: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("rows"),
|
||||
terminal: WireTerminal,
|
||||
lines: Schema.Array(Schema.String),
|
||||
cursor_x: Schema.Number,
|
||||
cursor_y: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("attached"),
|
||||
terminal: WireTerminal,
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Added, Handoff, Removed } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
@@ -103,6 +103,7 @@ export interface Interface {
|
||||
data: Uint8Array,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
|
||||
readonly read: (sessionID: Session.ID, lines?: number) => Effect.Effect<ReadResult | null, UnavailableError>
|
||||
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly shutdown: () => Effect.Effect<void, UnavailableError>
|
||||
readonly handoff: () => Effect.Effect<Handoff | null, UnavailableError>
|
||||
@@ -140,6 +141,8 @@ export const configured = (options: Options = {}) =>
|
||||
options.handoff,
|
||||
).pipe(Effect.mapError(unavailable))
|
||||
const removing = new Set<Pty.ID>()
|
||||
// Controller activity selects a terminal; observer reads and pane visibility do not.
|
||||
const current = new Map<Session.ID, Pty.ID>()
|
||||
|
||||
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
|
||||
const response = yield* optionalRequest(daemon, { op: "list" })
|
||||
@@ -207,7 +210,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "resize",
|
||||
id: fromID(id),
|
||||
@@ -216,6 +219,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
current.set(terminal.sessionID, id)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -225,7 +229,7 @@ export const configured = (options: Options = {}) =>
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
yield* get(id)
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "control",
|
||||
id: fromID(id),
|
||||
@@ -234,6 +238,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
current.set(terminal.sessionID, id)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -244,7 +249,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
yield* get(id)
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "input",
|
||||
id: fromID(id),
|
||||
@@ -254,6 +259,7 @@ export const configured = (options: Options = {}) =>
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
current.set(terminal.sessionID, id)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -269,16 +275,46 @@ export const configured = (options: Options = {}) =>
|
||||
}
|
||||
})
|
||||
|
||||
const read = Effect.fn("PersistentPty.read")(function* (sessionID: Session.ID, lines?: number) {
|
||||
if (lines !== undefined && !Schema.is(ReadLines)(lines))
|
||||
return yield* new UnavailableError({ message: "lines must be an integer between 1 and 65535" })
|
||||
const id = current.get(sessionID)
|
||||
if (!id) return null
|
||||
const terminal = yield* get(id).pipe(Effect.catchTag("PersistentPty.NotFoundError", () => Effect.succeed(null)))
|
||||
if (!terminal || terminal.sessionID !== sessionID) {
|
||||
if (current.get(sessionID) === id) current.delete(sessionID)
|
||||
return null
|
||||
}
|
||||
// Let the daemon choose the live height in the same snapshot when lines is omitted.
|
||||
const response = yield* request(daemon, { op: "read_rows", id: fromID(id), rows: lines })
|
||||
if (response.type !== "rows") return yield* unexpected(response)
|
||||
const info = toInfo(response.terminal)
|
||||
return {
|
||||
ptyID: info.id,
|
||||
title: info.title,
|
||||
cwd: info.cwd,
|
||||
foregroundProcess: info.foregroundProcess,
|
||||
screen: {
|
||||
text: response.lines.join("\n"),
|
||||
cols: info.size.cols,
|
||||
rows: info.size.rows,
|
||||
cursor: { x: response.cursor_x, y: response.cursor_y },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
if (current.get(terminal.sessionID) === id) current.delete(terminal.sessionID)
|
||||
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
|
||||
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
|
||||
current.clear()
|
||||
if (!response) return
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
})
|
||||
@@ -321,6 +357,7 @@ export const configured = (options: Options = {}) =>
|
||||
},
|
||||
})
|
||||
.pipe(Effect.mapError(unavailable))
|
||||
if (attachment.role === "controller") current.set(Session.ID.make(attachment.terminal.group_id), id)
|
||||
return {
|
||||
info: toInfo(attachment.terminal),
|
||||
role: attachment.role,
|
||||
@@ -340,6 +377,7 @@ export const configured = (options: Options = {}) =>
|
||||
control,
|
||||
input,
|
||||
snapshot,
|
||||
read,
|
||||
remove,
|
||||
shutdown,
|
||||
handoff,
|
||||
|
||||
+44
-107
@@ -1,5 +1,5 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source, PackageStatus, OperationError } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -26,7 +26,6 @@ import { Vcs } from "./vcs.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { PluginExecution } from "./plugin/execution.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (
|
||||
@@ -39,8 +38,6 @@ export interface Interface {
|
||||
export type Versioned = PluginDefinition & {
|
||||
readonly version: string
|
||||
readonly source?: Plugin.Source
|
||||
readonly revision?: string
|
||||
readonly generation?: string
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
@@ -50,12 +47,8 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const execution = yield* PluginExecution.Service
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<
|
||||
Plugin.ID,
|
||||
{ readonly plugin: Versioned; readonly scope: Scope.Closeable; readonly error?: string }
|
||||
>()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let inventory: Plugin.Info[] = []
|
||||
let host: Parameters<PluginDefinition["effect"]>[0]
|
||||
@@ -82,7 +75,7 @@ const layer = Layer.effect(
|
||||
"plugin.id": plugin.id,
|
||||
cause: loaded.cause,
|
||||
})
|
||||
return { error: errorMessage(loaded.cause) } as const
|
||||
return { error: Cause.pretty(loaded.cause) } as const
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
@@ -105,84 +98,55 @@ const layer = Layer.effect(
|
||||
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
|
||||
})
|
||||
) {
|
||||
const nextInventory = withFailures(
|
||||
Array.from(active.values(), (entry) => activeInfo(entry.plugin, entry.error)),
|
||||
failures,
|
||||
)
|
||||
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
|
||||
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||
inventory = nextInventory
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
return
|
||||
}
|
||||
|
||||
yield* execution.exclusive(
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
const nextInventory: Plugin.Info[] = []
|
||||
// Registrations are append-ordered. Preserve the unchanged prefix, then rebuild the suffix.
|
||||
const current = Array.from(active.values(), (entry) => entry.plugin)
|
||||
const prefix = definitions.findIndex(
|
||||
(definition, index) =>
|
||||
definition.id !== current[index]?.id || definition.version !== current[index]?.version,
|
||||
)
|
||||
const keep = prefix === -1 ? definitions.length : prefix
|
||||
const previousEntries = new Map(active)
|
||||
const closing = Array.from(active.entries()).slice(keep).toReversed()
|
||||
yield* Effect.forEach(
|
||||
closing,
|
||||
([id, entry]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Scope.close(entry.scope, Exit.void).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to clean up plugin", { "plugin.id": id, cause }),
|
||||
),
|
||||
)
|
||||
active.delete(id)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
for (const [index, definition] of definitions.entries()) {
|
||||
if (index < keep) {
|
||||
nextInventory.push(activeInfo(definition, active.get(definition.id)?.error))
|
||||
continue
|
||||
}
|
||||
const previous = previousEntries.get(definition.id)
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const nextInventory: Plugin.Info[] = []
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
|
||||
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
const failed: Plugin.Info = {
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
status: "failed",
|
||||
error: loaded.error,
|
||||
tui: definition.tui ?? false,
|
||||
...(definition.revision === undefined ? {} : { revision: definition.revision }),
|
||||
...(definition.generation === undefined ? {} : { generation: definition.generation }),
|
||||
}
|
||||
|
||||
if (!previous) {
|
||||
nextInventory.push(failed)
|
||||
continue
|
||||
}
|
||||
const restored = yield* load(previous.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope, error: loaded.error })
|
||||
nextInventory.push({ ...activeInfo(previous.plugin), error: loaded.error })
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
"plugin.id": definition.id,
|
||||
})
|
||||
nextInventory.push(failed)
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
status: "failed",
|
||||
error: loaded.error,
|
||||
tui: definition.tui ?? false,
|
||||
})
|
||||
|
||||
inventory = withFailures(nextInventory, failures)
|
||||
}),
|
||||
).pipe(Effect.uninterruptible),
|
||||
if (!previous) continue
|
||||
const restored = yield* load(previous.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
"plugin.id": definition.id,
|
||||
})
|
||||
}
|
||||
|
||||
const removed = Array.from(active.entries())
|
||||
.filter(([id]) => !ids.has(id))
|
||||
.toReversed()
|
||||
removed.forEach(([id]) => active.delete(id))
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
|
||||
discard: true,
|
||||
})
|
||||
inventory = [...nextInventory, ...failures]
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
}),
|
||||
@@ -207,41 +171,15 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function activeInfo(plugin: Versioned, error?: string): Plugin.Info {
|
||||
function activeInfo(plugin: Versioned): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
status: "active",
|
||||
tui: plugin.tui ?? false,
|
||||
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
|
||||
...(plugin.generation === undefined ? {} : { generation: plugin.generation }),
|
||||
...(error === undefined ? {} : { error }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Inventory errors must not expose credentials or unbounded import/setup traces. */
|
||||
export function errorMessage(cause: Cause.Cause<unknown>) {
|
||||
return Cause.pretty(cause)
|
||||
.replace(/https?:\/\/[^\s"'<>]+/g, (value) => {
|
||||
if (!URL.canParse(value)) return "[URL]"
|
||||
const url = new URL(value)
|
||||
url.username = ""
|
||||
url.password = ""
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
return url.href
|
||||
})
|
||||
.slice(0, 2000)
|
||||
}
|
||||
|
||||
function withFailures(active: Plugin.Info[], failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[]) {
|
||||
return failures.reduce((inventory, failure) => {
|
||||
const index = inventory.findIndex((entry) => JSON.stringify(entry.source) === JSON.stringify(failure.source))
|
||||
if (index === -1) return [...inventory, failure]
|
||||
return inventory.map((entry, current) => (current === index ? { ...entry, error: failure.error } : entry))
|
||||
}, active)
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
@@ -265,6 +203,5 @@ export const node = makeLocationNode({
|
||||
WebSearch.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
PluginExecution.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
export * as PluginExecution from "./execution.js"
|
||||
|
||||
import { Context, Effect, Latch, Layer, Semaphore } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
export interface Interface {
|
||||
readonly lease: <A, E, R>(
|
||||
session: { readonly id: string; readonly parentID?: string; readonly admission?: boolean },
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
) => Effect.Effect<A, E, R>
|
||||
readonly exclusive: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginExecution") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const admission = yield* Latch.make(true)
|
||||
const idle = yield* Latch.make(true)
|
||||
const mutations = yield* Semaphore.make(1)
|
||||
const readers = new Map<string, number>()
|
||||
let leases = 0
|
||||
|
||||
const acquire = (session: {
|
||||
readonly id: string
|
||||
readonly parentID?: string
|
||||
readonly admission?: boolean
|
||||
}): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
// Child execution must finish the parent's tool call even while activation waits.
|
||||
if (
|
||||
!admission.isOpen() &&
|
||||
!readers.has(session.id) &&
|
||||
!(session.parentID && readers.has(session.parentID)) &&
|
||||
!(session.admission && leases > 0)
|
||||
)
|
||||
return Effect.interruptible(admission.await).pipe(Effect.andThen(acquire(session)))
|
||||
// Admission protects callbacks but does not establish Session execution ownership.
|
||||
if (!session.admission) readers.set(session.id, (readers.get(session.id) ?? 0) + 1)
|
||||
leases++
|
||||
return idle.close
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
lease: (session, effect) =>
|
||||
Effect.acquireUseRelease(
|
||||
acquire(session),
|
||||
() => effect,
|
||||
() =>
|
||||
Effect.suspend(() => {
|
||||
leases--
|
||||
if (!session.admission) {
|
||||
const count = (readers.get(session.id) ?? 1) - 1
|
||||
if (count === 0) readers.delete(session.id)
|
||||
else readers.set(session.id, count)
|
||||
}
|
||||
return leases === 0 ? idle.open : Effect.void
|
||||
}),
|
||||
),
|
||||
exclusive: (effect) =>
|
||||
mutations.withPermit(
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
admission.close.pipe(
|
||||
Effect.andThen(restore(idle.await).pipe(Effect.andThen(restore(effect)))),
|
||||
Effect.ensuring(admission.open),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
@@ -4,7 +4,6 @@ import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { ServerConfig } from "@opencode-ai/schema/mcp"
|
||||
import { OperationError } from "@opencode-ai/schema/plugin"
|
||||
import { App } from "../app.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -194,6 +193,11 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
event: {
|
||||
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||
},
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: (input) => runtime.persistentPty.read(input.sessionID, input.lines),
|
||||
},
|
||||
},
|
||||
generate: {
|
||||
text: (input) => generate.text(input).pipe(Effect.map((text) => ({ text }))),
|
||||
},
|
||||
@@ -328,10 +332,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
},
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
// In-process management can wait on its own setup/tool lease. Use an external client instead.
|
||||
check: () => Effect.fail(new OperationError({ message: "Check plugin updates through an external client." })),
|
||||
update: () => Effect.fail(new OperationError({ message: "Update plugins through an external client." })),
|
||||
reload: () => Effect.fail(new OperationError({ message: "Reload plugins through an external client." })),
|
||||
},
|
||||
reference: {
|
||||
list: () => response(reference.list()),
|
||||
|
||||
@@ -36,22 +36,15 @@ const Definition = Schema.Struct({
|
||||
|
||||
export const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
fresh?: Npm.EntryPoint,
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const resolved =
|
||||
fresh ??
|
||||
(path.isAbsolute(operation.target)
|
||||
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined, generation: undefined }
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true }))
|
||||
const entrypoint = resolved.entrypoint
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target =
|
||||
typeof Bun !== "undefined" && path.isAbsolute(operation.target)
|
||||
? operation.target.replaceAll("\\", "/")
|
||||
: entrypoint
|
||||
const source = fresh || operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => importModule(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
|
||||
@@ -60,9 +53,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
vcs: plugin.vcs,
|
||||
version: JSON.stringify([operation, resolved.generation]),
|
||||
revision: resolved.revision,
|
||||
generation: resolved.generation,
|
||||
version: JSON.stringify(operation),
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: operation.target }
|
||||
: { type: "package" as const, package: operation.target },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PersistentPty } from "../persistent-pty.js"
|
||||
import { Session } from "../session.js"
|
||||
|
||||
export interface Interface {
|
||||
@@ -29,6 +30,7 @@ export interface Interface {
|
||||
| "context"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
|
||||
readonly persistentPty: Pick<PersistentPty.Interface, "read">
|
||||
readonly location: {
|
||||
readonly agent: {
|
||||
readonly list: (
|
||||
@@ -47,16 +49,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pl
|
||||
|
||||
export interface Cell {
|
||||
runtime?: Interface
|
||||
readonly ready?: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const makeCell = (): Cell => ({})
|
||||
export const makeCell = (ready?: Effect.Effect<void>): Cell => ({ ready })
|
||||
|
||||
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
|
||||
Effect.suspend(() => {
|
||||
const runtime = cell.runtime
|
||||
if (runtime === undefined) return Effect.die(new Error("Plugin runtime is unavailable"))
|
||||
return f(runtime)
|
||||
})
|
||||
(cell.ready ?? Effect.void).pipe(
|
||||
Effect.andThen(() => {
|
||||
const runtime = cell.runtime
|
||||
if (runtime === undefined) return Effect.die(new Error("Plugin runtime is unavailable"))
|
||||
return f(runtime)
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultCell = makeCell()
|
||||
|
||||
@@ -90,6 +95,9 @@ export const layerWithCell = (cell: Cell) =>
|
||||
completeBackground: (notificationID) =>
|
||||
require(cell, (runtime) => runtime.job.completeBackground(notificationID)),
|
||||
},
|
||||
persistentPty: {
|
||||
read: (sessionID, lines) => require(cell, (runtime) => runtime.persistentPty.read(sessionID, lines)),
|
||||
},
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
|
||||
@@ -107,9 +115,11 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
const sessions = yield* Session.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const runtime: Interface = {
|
||||
session: sessions,
|
||||
job: jobs,
|
||||
persistentPty,
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) =>
|
||||
@@ -162,7 +172,7 @@ export const providerNodeWithCell = (cell: Cell) =>
|
||||
makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayerWithCell(cell),
|
||||
deps: [node, Session.node, Job.node, LocationServiceMap.node],
|
||||
deps: [node, Session.node, Job.node, LocationServiceMap.node, PersistentPty.node],
|
||||
})
|
||||
|
||||
export const providerNode = providerNodeWithCell(defaultCell)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as PluginSupervisor from "./supervisor-service.js"
|
||||
|
||||
import { Context, Effect } from "effect"
|
||||
import type { Plugin } from "@opencode-ai/schema/plugin"
|
||||
|
||||
/**
|
||||
* Dependency-only supervisor seam. Keep this module free of implementation
|
||||
@@ -10,11 +9,6 @@ import type { Plugin } from "@opencode-ai/schema/plugin"
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
/** Initial reconciliation barrier; it never closes again after startup settles. */
|
||||
readonly initialized: Effect.Effect<void>
|
||||
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.OperationError>
|
||||
readonly update: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
|
||||
readonly reload: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
@@ -2,10 +2,8 @@ export * as PluginSupervisor from "./supervisor.js"
|
||||
export { Service, type Interface } from "./supervisor-service.js"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Effect, Latch, Layer, Option, Scope, Semaphore, Stream } from "effect"
|
||||
import { Cause, Effect, Latch, Layer, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -17,13 +15,10 @@ import { PluginModule } from "./module.js"
|
||||
import { SdkPlugins } from "./sdk.js"
|
||||
import { Service } from "./supervisor-service.js"
|
||||
|
||||
type Cached = { readonly operation: string; readonly plugin: Plugin.Versioned; readonly error?: string }
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
pre: readonly Plugin.Versioned[],
|
||||
post: readonly Plugin.Versioned[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
cache: Map<string, Cached>,
|
||||
) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
@@ -31,7 +26,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
||||
const packages = new Map<string, Plugin.Versioned>()
|
||||
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
|
||||
const previousSources = new Map(cache)
|
||||
const plugins = () => [...definitions, ...packages.values()]
|
||||
|
||||
for (const operation of operations) {
|
||||
@@ -54,23 +48,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
const cached = previousSources.get(operation.target)
|
||||
const key = JSON.stringify(operation)
|
||||
const plugin =
|
||||
cached?.operation === key
|
||||
? cached.plugin
|
||||
: yield* PluginModule.load(operation).pipe(
|
||||
Effect.flatMap((plugin) =>
|
||||
cached && plugin.id !== cached.plugin.id
|
||||
? Effect.fail(new Error(`Plugin ID changed from ${cached.plugin.id} to ${plugin.id}`))
|
||||
: Effect.succeed(plugin),
|
||||
),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
|
||||
Effect.as({ error: Plugin.errorMessage(cause) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const plugin = yield* PluginModule.load(operation).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
|
||||
Effect.as({ error: Cause.pretty(cause) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
if ("error" in plugin) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
@@ -78,24 +62,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
error: plugin.error,
|
||||
tui: false,
|
||||
})
|
||||
if (cached) {
|
||||
cache.set(operation.target, { ...cached, operation: key, error: plugin.error })
|
||||
packages.set(operation.target, cached.plugin)
|
||||
enabled.add(cached.plugin.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (cached?.operation === key && cached.error) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
status: "failed",
|
||||
error: cached.error,
|
||||
tui: plugin.tui ?? false,
|
||||
})
|
||||
} else {
|
||||
failures.delete(operation.target)
|
||||
cache.set(operation.target, { operation: key, plugin })
|
||||
}
|
||||
failures.delete(operation.target)
|
||||
const previous = packages.get(operation.target)
|
||||
if (previous) enabled.delete(previous.id)
|
||||
packages.set(operation.target, plugin)
|
||||
@@ -108,12 +77,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
],
|
||||
failures: [...failures.entries()]
|
||||
.filter(([target]) => {
|
||||
const plugin = packages.get(target)
|
||||
return !plugin || enabled.has(plugin.id)
|
||||
})
|
||||
.map(([, failure]) => failure),
|
||||
failures: [...failures.values()],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -125,20 +89,12 @@ export const layer = Layer.effect(
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const npm = yield* Npm.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const ready = yield* Latch.make()
|
||||
const initialized = yield* Latch.make()
|
||||
const mutations = yield* Semaphore.make(1)
|
||||
const cache = new Map<string, Cached>()
|
||||
// Internal definitions capture stable Location services; SDK contributions remain dynamic.
|
||||
const internal = yield* PluginInternal.list()
|
||||
let observed = 0
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* (
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
previous: ReadonlyMap<string, Cached> = new Map(cache),
|
||||
) {
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed plugins in boot order.
|
||||
// Instance-bound plugins come last: later activation can override earlier
|
||||
// container writes, so the instance's explicit choices win over globals.
|
||||
@@ -152,117 +108,12 @@ export const layer = Layer.effect(
|
||||
version: "internal",
|
||||
source: { type: "builtin" as const },
|
||||
}))
|
||||
const operations = yield* sources.operations()
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const resolved = yield* resolve(pre, post, operations, cache).pipe(Effect.provideService(Npm.Service, npm))
|
||||
const resolved = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
const activated = yield* registry.activate(resolved.plugins, resolved.failures).pipe(Effect.exit)
|
||||
if (activated._tag === "Failure") {
|
||||
cache.clear()
|
||||
previous.forEach((entry, target) => cache.set(target, entry))
|
||||
return yield* Effect.failCause(activated.cause)
|
||||
}
|
||||
const inventory = yield* registry.list()
|
||||
for (const [target, entry] of cache) {
|
||||
if (!operations.some((operation) => operation.type === "add" && operation.target === target)) {
|
||||
cache.delete(target)
|
||||
continue
|
||||
}
|
||||
const active = inventory.find((item) =>
|
||||
item.source.type === "local"
|
||||
? item.source.path === target
|
||||
: item.source.type === "package" && item.source.package === target,
|
||||
)
|
||||
if (active?.status !== "active" || !active.error) continue
|
||||
const old = previous.get(target)
|
||||
if (old) cache.set(target, { ...old, operation: entry.operation, error: active.error })
|
||||
}
|
||||
return inventory
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
})
|
||||
|
||||
const operation = Effect.fnUntraced(function* (target: string) {
|
||||
const operations = yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const entry = operations.findLast((entry) => entry.type === "add" && entry.target === target)
|
||||
const inventory = yield* registry.list()
|
||||
if (
|
||||
!entry ||
|
||||
entry.type !== "add" ||
|
||||
target === "*" ||
|
||||
target.endsWith(".*") ||
|
||||
target.startsWith("opencode.") ||
|
||||
(!cache.has(target) &&
|
||||
!inventory.some((item) =>
|
||||
item.source.type === "local"
|
||||
? item.source.path === target
|
||||
: item.source.type === "package" && item.source.package === target,
|
||||
))
|
||||
)
|
||||
return yield* new Plugin.OperationError({ message: `Not a configured plugin source: ${target}` })
|
||||
return { entry, operations }
|
||||
})
|
||||
|
||||
const mutate = (target: string, action: "update" | "reload") =>
|
||||
initialized.await.pipe(
|
||||
Effect.andThen(
|
||||
mutations.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* operation(target)
|
||||
if (action === "update") {
|
||||
if (path.isAbsolute(target) || !(yield* npm.inspect(target)).mutable)
|
||||
return yield* new Plugin.OperationError({ message: `Plugin source cannot be updated: ${target}` })
|
||||
}
|
||||
const previous = new Map(cache)
|
||||
const generation = randomUUID()
|
||||
const staged = path.isAbsolute(target)
|
||||
? {
|
||||
directory: path.dirname(target),
|
||||
generation,
|
||||
entrypoint: `${typeof Bun !== "undefined" ? target.replaceAll("\\", "/") : pathToFileURL(target).href}?reload=${generation}`,
|
||||
}
|
||||
: yield* action === "update"
|
||||
? npm.update(target, { subpaths: ["server", ""] })
|
||||
: npm.reload(target, {
|
||||
subpaths: ["server", ""],
|
||||
revision: cache.get(target)?.plugin.revision,
|
||||
generation: cache.get(target)?.plugin.generation,
|
||||
})
|
||||
const loaded = yield* PluginModule.load(selected.entry, staged).pipe(
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.exit,
|
||||
)
|
||||
if (loaded._tag === "Failure") {
|
||||
const old = cache.get(target)
|
||||
if (!old) return yield* Effect.failCause(loaded.cause)
|
||||
cache.set(target, {
|
||||
...old,
|
||||
operation: JSON.stringify(selected.entry),
|
||||
error: Plugin.errorMessage(loaded.cause),
|
||||
})
|
||||
return yield* activate(selected.operations, previous)
|
||||
}
|
||||
const old = cache.get(target)
|
||||
if (old && loaded.value.id !== old.plugin.id) {
|
||||
cache.set(target, {
|
||||
...old,
|
||||
operation: JSON.stringify(selected.entry),
|
||||
error: `Plugin ID changed from ${old.plugin.id} to ${loaded.value.id}`,
|
||||
})
|
||||
return yield* activate(selected.operations, previous)
|
||||
}
|
||||
cache.set(target, { operation: JSON.stringify(selected.entry), plugin: loaded.value })
|
||||
return yield* activate(selected.operations, previous)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
|
||||
return Effect.fail(
|
||||
error instanceof Plugin.OperationError
|
||||
? error
|
||||
: new Plugin.OperationError({ message: `Unable to ${action} plugin source.` }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
@@ -279,48 +130,13 @@ export const layer = Layer.effect(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mutations
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
return yield* activate(yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope)))
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
|
||||
if (observed === target) {
|
||||
yield* initialized.open
|
||||
yield* ready.open
|
||||
}
|
||||
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
|
||||
if (observed === target) yield* ready.open
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({
|
||||
flush: ready.await,
|
||||
initialized: initialized.await,
|
||||
check: (target) =>
|
||||
initialized.await.pipe(
|
||||
Effect.andThen(
|
||||
mutations.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* operation(target)
|
||||
if (path.isAbsolute(target)) return { mutable: false }
|
||||
return yield* npm.check(target)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
|
||||
return Effect.fail(
|
||||
error instanceof Plugin.OperationError
|
||||
? error
|
||||
: new Plugin.OperationError({ message: "Unable to check plugin source." }),
|
||||
)
|
||||
}),
|
||||
),
|
||||
update: (target) => mutate(target, "update"),
|
||||
reload: (target) => mutate(target, "reload"),
|
||||
})
|
||||
return Service.of({ flush: ready.await })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
+105
-142
@@ -1,12 +1,12 @@
|
||||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { Cause, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Workspace } from "./workspace.js"
|
||||
import { Model } from "./model.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
|
||||
@@ -17,7 +17,7 @@ import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable, SessionTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { Agent } from "./agent.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
@@ -27,10 +27,9 @@ import { fromRow } from "./session/info.js"
|
||||
import { SessionRunner } from "./session/runner/index.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionInstance } from "./session/instance.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionInbox } from "./session/inbox.js"
|
||||
import { InstructionState } from "./session/instruction-state.js"
|
||||
@@ -41,7 +40,6 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Image } from "./image.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor-service.js"
|
||||
import { PluginExecution } from "./plugin/execution.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { Mime } from "./mime.js"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
@@ -100,6 +98,8 @@ type CreateBaseInput = {
|
||||
agent?: Agent.ID
|
||||
model?: Model.Ref
|
||||
metadata?: SessionSchema.Metadata
|
||||
/** Runtime discovery policy; never recorded as a Session fact. */
|
||||
discovery?: boolean
|
||||
}
|
||||
type CreateInput = CreateBaseInput &
|
||||
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
|
||||
@@ -343,23 +343,13 @@ const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const instances = yield* SessionInstance.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
|
||||
const location = Location.Ref.make({
|
||||
directory: session.location.directory,
|
||||
workspaceID: session.location.workspaceID,
|
||||
})
|
||||
if (!(yield* RcMap.has(locations.rcMap, location))) return
|
||||
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
|
||||
Effect.provide(locations.get(location)),
|
||||
)
|
||||
})
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
|
||||
@@ -396,7 +386,7 @@ const layer = Layer.effect(
|
||||
const location = parent?.location ?? input.location
|
||||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
const project = yield* projects.resolve(location.directory, { discovery: input.discovery })
|
||||
yield* persistProject(project)
|
||||
const projected = yield* bus
|
||||
.publish(
|
||||
@@ -511,7 +501,7 @@ const layer = Layer.effect(
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* execution.interrupt(sessionID)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
yield* closeTransport(session)
|
||||
yield* instances.closeTransport(session)
|
||||
const children = yield* result.list({ parentID: sessionID })
|
||||
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
|
||||
yield* environments.clear(sessionID)
|
||||
@@ -659,7 +649,7 @@ const layer = Layer.effect(
|
||||
if (existing) return existing
|
||||
const item = yield* restore(
|
||||
preparePrompt(input, messageID).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.provide(instances.get(session)),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
),
|
||||
)
|
||||
@@ -691,39 +681,30 @@ const layer = Layer.effect(
|
||||
),
|
||||
generate: Effect.fn("Session.generate")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.initialized
|
||||
const gate = yield* PluginExecution.Service
|
||||
const generate = yield* SessionGenerate.Service
|
||||
return yield* gate.lease({ id: session.id, admission: true }, generate.generate(input))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(instances.get(session)))
|
||||
return yield* generate.generate(input)
|
||||
}),
|
||||
command: Effect.fn("Session.command")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
yield* Effect.gen(function* () {
|
||||
const commands = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.initialized
|
||||
const gate = yield* PluginExecution.Service
|
||||
const commands = yield* Command.Service
|
||||
// Commands may await child Sessions, so they retain ownership that lets those children drain.
|
||||
return yield* gate.lease(
|
||||
session,
|
||||
commands.execute({
|
||||
name: input.command,
|
||||
invocation: {
|
||||
sessionID: input.sessionID,
|
||||
prompt: {
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
},
|
||||
delivery: input.delivery ?? "steer",
|
||||
},
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* plugins.flush
|
||||
return yield* Command.Service
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
const delivery = input.delivery ?? "steer"
|
||||
yield* commands.execute({
|
||||
name: input.command,
|
||||
invocation: {
|
||||
sessionID: input.sessionID,
|
||||
prompt: {
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
},
|
||||
delivery,
|
||||
},
|
||||
})
|
||||
}),
|
||||
shell: Effect.fn("Session.shell")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
@@ -733,21 +714,17 @@ const layer = Layer.effect(
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
const started = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.initialized
|
||||
const gate = yield* PluginExecution.Service
|
||||
yield* plugins.flush
|
||||
const shell = yield* Shell.Service
|
||||
return yield* gate.lease(
|
||||
{ id: input.sessionID, admission: true },
|
||||
shell
|
||||
.create({
|
||||
command: input.command,
|
||||
cwd: session.location.directory,
|
||||
timeout: 0,
|
||||
metadata: { sessionID: input.sessionID },
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
return yield* shell
|
||||
.create({
|
||||
command: input.command,
|
||||
cwd: session.location.directory,
|
||||
timeout: 0,
|
||||
metadata: { sessionID: input.sessionID },
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
yield* bus.publish(
|
||||
SessionEvent.Shell.Started,
|
||||
{
|
||||
@@ -770,7 +747,7 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
|
||||
: missingShellOutput()
|
||||
return { shell: terminal.info, output }
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
yield* bus.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
shell: completed.shell,
|
||||
@@ -788,7 +765,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
skill: Effect.fn("Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(instances.get(session)))
|
||||
const skill = yield* skills.get(input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* bus.publish(
|
||||
@@ -851,7 +828,7 @@ const layer = Layer.effect(
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
}
|
||||
yield* Location.Service.pipe(
|
||||
Effect.provide(locations.get(payload.location)),
|
||||
Effect.provide(instances.destination(payload.location)),
|
||||
Effect.scoped,
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
@@ -978,29 +955,21 @@ const layer = Layer.effect(
|
||||
return yield* new BusyError({ sessionID: input.sessionID })
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.initialized
|
||||
const gate = yield* PluginExecution.Service
|
||||
return yield* gate.lease(
|
||||
{ id: session.id, admission: true },
|
||||
SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
),
|
||||
yield* plugins.flush
|
||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
}),
|
||||
clear: Effect.fn("Session.revert.clear")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||
const revert = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.initialized
|
||||
const gate = yield* PluginExecution.Service
|
||||
return yield* gate.lease(
|
||||
{ id: session.id, admission: true },
|
||||
SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus)),
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* plugins.flush
|
||||
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
yield* execution.wake(sessionID)
|
||||
return revert
|
||||
}),
|
||||
@@ -1041,65 +1010,59 @@ const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
|
||||
messageID: SessionMessage.ID,
|
||||
) {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.initialized
|
||||
const gate = yield* PluginExecution.Service
|
||||
return yield* gate.lease(
|
||||
{ id: request.sessionID, admission: true },
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "prompt", {
|
||||
sessionID: request.sessionID,
|
||||
messageID,
|
||||
prompt: structuredClone({
|
||||
text: request.text,
|
||||
files: request.files?.slice(),
|
||||
agents: request.agents?.slice(),
|
||||
skills: request.skills?.slice(),
|
||||
}),
|
||||
metadata: structuredClone(request.metadata),
|
||||
delivery: request.delivery ?? "steer",
|
||||
})
|
||||
const input = event.prompt
|
||||
const fs = yield* FSUtil.Service
|
||||
const files = input.files
|
||||
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
|
||||
: undefined
|
||||
const requested = input.skills
|
||||
const selected = yield* Effect.gen(function* () {
|
||||
if (!requested?.length) return undefined
|
||||
const skillService = yield* Skill.Service
|
||||
const prepared = new Map<Skill.ID, Skill.Name>()
|
||||
return yield* Effect.forEach(requested, (attachment) =>
|
||||
Effect.gen(function* () {
|
||||
const name = prepared.get(attachment.id)
|
||||
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
|
||||
const skill = yield* skillService.get(attachment.id)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
|
||||
prepared.set(skill.id, skill.name)
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
|
||||
mention: attachment.mention,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
return SessionInbox.Item.make({
|
||||
type: "user",
|
||||
payload: {
|
||||
...Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
files,
|
||||
skills: selected?.length ? selected : undefined,
|
||||
}),
|
||||
metadata: event.metadata,
|
||||
},
|
||||
delivery: event.delivery,
|
||||
})
|
||||
yield* plugins.flush
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "prompt", {
|
||||
sessionID: request.sessionID,
|
||||
messageID,
|
||||
prompt: structuredClone({
|
||||
text: request.text,
|
||||
files: request.files?.slice(),
|
||||
agents: request.agents?.slice(),
|
||||
skills: request.skills?.slice(),
|
||||
}),
|
||||
)
|
||||
metadata: structuredClone(request.metadata),
|
||||
delivery: request.delivery ?? "steer",
|
||||
})
|
||||
const input = event.prompt
|
||||
const fs = yield* FSUtil.Service
|
||||
const files = input.files
|
||||
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
|
||||
: undefined
|
||||
const requested = input.skills
|
||||
const selected = yield* Effect.gen(function* () {
|
||||
if (!requested?.length) return undefined
|
||||
const skillService = yield* Skill.Service
|
||||
const prepared = new Map<Skill.ID, Skill.Name>()
|
||||
return yield* Effect.forEach(requested, (attachment) =>
|
||||
Effect.gen(function* () {
|
||||
const name = prepared.get(attachment.id)
|
||||
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
|
||||
const skill = yield* skillService.get(attachment.id)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
|
||||
prepared.set(skill.id, skill.name)
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
|
||||
mention: attachment.mention,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
return SessionInbox.Item.make({
|
||||
type: "user",
|
||||
payload: {
|
||||
...Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
files,
|
||||
skills: selected?.length ? selected : undefined,
|
||||
}),
|
||||
metadata: event.metadata,
|
||||
},
|
||||
delivery: event.delivery,
|
||||
})
|
||||
})
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
@@ -1250,7 +1213,7 @@ export const node = makeGlobalNode({
|
||||
Project.node,
|
||||
SessionExecution.node,
|
||||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
SessionInstance.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
export * as SessionBindings from "./bindings.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Instance } from "../instance.js"
|
||||
import { Location } from "../location.js"
|
||||
import type { SessionExecution } from "./execution.js"
|
||||
import { SessionInstance } from "./instance.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
|
||||
export class AlreadyBoundError extends Schema.TaggedError<AlreadyBoundError>()("Session.AlreadyBoundError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export class ClosedError extends Schema.TaggedError<ClosedError>()("Session.ClosedError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export interface Binding {
|
||||
readonly check: Effect.Effect<void, ClosedError>
|
||||
readonly activate: (context: Context.Context<Instance.Services>) => Effect.Effect<void>
|
||||
readonly shutdown: (execution: SessionExecution.Interface) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly reserve: (sessionID: SessionSchema.ID) => Effect.Effect<Binding, AlreadyBoundError, Scope.Scope>
|
||||
readonly instances: SessionInstance.Interface
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionBindings") {}
|
||||
|
||||
type Entry = {
|
||||
readonly ids: Set<SessionSchema.ID>
|
||||
context?: Context.Context<Instance.Services>
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const entries = new Map<SessionSchema.ID, Entry>()
|
||||
// Children use their nearest explicitly bound ancestor. Remember every used
|
||||
// child so closing one instance settles its whole execution ownership chain.
|
||||
const find = (session: SessionSchema.Info): Effect.Effect<Entry | undefined> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = entries.get(session.id)
|
||||
if (entry) return Effect.succeed(entry)
|
||||
if (!session.parentID) return Effect.succeed(undefined)
|
||||
return store
|
||||
.get(session.parentID)
|
||||
.pipe(Effect.flatMap((parent) => (parent ? find(parent) : Effect.succeed(undefined))))
|
||||
})
|
||||
const selected = Effect.fn("SessionBindings.selected")(function* (session: SessionSchema.Info) {
|
||||
const entry = yield* find(session)
|
||||
if (!entry || entry.closed || !entry.context)
|
||||
return yield* Effect.die(new Error(`Session has no live bound instance: ${session.id}`))
|
||||
const location = Context.get(entry.context, Location.Service)
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.die(new Error(`Bound Session placement changed: ${session.id}`))
|
||||
entry.ids.add(session.id)
|
||||
entries.set(session.id, entry)
|
||||
return entry.context
|
||||
})
|
||||
return Service.of({
|
||||
reserve: (sessionID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
if (yield* find(session)) return yield* new AlreadyBoundError({ sessionID })
|
||||
const entry: Entry = { ids: new Set([sessionID]), closed: false }
|
||||
entries.set(sessionID, entry)
|
||||
const release = Effect.sync(() => {
|
||||
entry.closed = true
|
||||
entry.context = undefined
|
||||
entry.ids.forEach((id) => {
|
||||
if (entries.get(id) === entry) entries.delete(id)
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => release)
|
||||
return {
|
||||
check: Effect.suspend(() => (entry.closed ? Effect.fail(new ClosedError({ sessionID })) : Effect.void)),
|
||||
activate: (context) =>
|
||||
Effect.sync(() => {
|
||||
entry.context = context
|
||||
}),
|
||||
shutdown: (execution) =>
|
||||
Effect.sync(() => {
|
||||
entry.closed = true
|
||||
}).pipe(Effect.andThen(execution.shutdown(Array.from(entry.ids))), Effect.ensuring(release)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
instances: {
|
||||
get: (session) => Layer.effectContext(selected(session)),
|
||||
check: (sessionID) =>
|
||||
store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? selected(session) : Effect.die(new Error(`Session not found: ${sessionID}`)),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
closeTransport: (session) =>
|
||||
selected(session).pipe(
|
||||
Effect.flatMap((context) => Context.get(context, SessionModelTransport.Service).close(session.id)),
|
||||
),
|
||||
destination: () =>
|
||||
Layer.effect(Location.Service, Effect.die(new Error("Direct Sessions do not support movement"))),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node] })
|
||||
|
||||
export const instanceNode = makeGlobalNode({
|
||||
service: SessionInstance.Service,
|
||||
layer: Layer.effect(
|
||||
SessionInstance.Service,
|
||||
Effect.map(Service, (bindings) => bindings.instances),
|
||||
),
|
||||
deps: [node],
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -65,13 +64,6 @@ export type Draft = {
|
||||
configure: (settings: Partial<Settings>) => void
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
@@ -240,195 +232,196 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
|
||||
}
|
||||
}
|
||||
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const state = State.create<Settings, Draft>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
draft: (draft) => ({
|
||||
configure: (settings) => {
|
||||
if (settings.auto !== undefined) draft.auto = settings.auto
|
||||
if (settings.buffer !== undefined) draft.buffer = settings.buffer
|
||||
if (settings.tokens !== undefined) draft.tokens = settings.tokens
|
||||
},
|
||||
}),
|
||||
})
|
||||
const failed = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly error: SessionError.Error
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}) {
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Failed, input)
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||
if (!plan.started)
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
recent: plan.recent,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: plan.session.id,
|
||||
source: "compaction",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* recordUsage
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
}
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
text: summary,
|
||||
recent: plan.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved: input.resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
})
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
const limit = input.resolved.limit
|
||||
const context = limit.context
|
||||
if (context <= 0) return false
|
||||
const last = input.messages.findLast(
|
||||
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
|
||||
const promptCeiling = Math.min(
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const used =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
return used >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (!content)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in resolved) return resolved
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
})
|
||||
})
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
enabled: () => state.get().auto,
|
||||
required,
|
||||
compact,
|
||||
compactManual,
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
return make({ bus, llm })
|
||||
|
||||
const state = State.create<Settings, Draft>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
draft: (draft) => ({
|
||||
configure: (settings) => {
|
||||
if (settings.auto !== undefined) draft.auto = settings.auto
|
||||
if (settings.buffer !== undefined) draft.buffer = settings.buffer
|
||||
if (settings.tokens !== undefined) draft.tokens = settings.tokens
|
||||
},
|
||||
}),
|
||||
})
|
||||
const failed = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly error: SessionError.Error
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}) {
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, input)
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||
if (!plan.started)
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
recent: plan.recent,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: plan.session.id,
|
||||
source: "compaction",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* recordUsage
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
text: summary,
|
||||
recent: plan.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved: input.resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
})
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
const limit = input.resolved.limit
|
||||
const context = limit.context
|
||||
if (context <= 0) return false
|
||||
const last = input.messages.findLast(
|
||||
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
|
||||
const promptCeiling = Math.min(
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const used =
|
||||
last.tokens.input +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
return used >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (!content)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in resolved) return resolved
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
})
|
||||
})
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
enabled: () => state.get().auto,
|
||||
required,
|
||||
compact,
|
||||
compactManual,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ const layer = Layer.effect(
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
|
||||
yield* plugins.initialized
|
||||
yield* plugins.flush
|
||||
yield* mcpTools.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
export * as DirectSession from "./direct.js"
|
||||
|
||||
import { Context, Effect, Exit, Fiber, Latch, Layer, Scope, Stream } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Instance } from "../instance.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PluginRuntime } from "../plugin/runtime.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Session } from "../session.js"
|
||||
import { Shared } from "../shared.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
|
||||
export { AlreadyBoundError, ClosedError } from "./bindings.js"
|
||||
export { ID } from "./schema.js"
|
||||
|
||||
type Facts = Omit<Parameters<Session.Interface["create"]>[0], "id" | "location" | "parentID" | "discovery">
|
||||
export type Options<Items extends Shared.Replacements = Shared.Replacements> = Facts &
|
||||
Omit<Instance.Options, "replacements"> & {
|
||||
readonly replacements?: LayerNode.ComposableReplacements<Items>
|
||||
} & (
|
||||
| { readonly id: SessionSchema.ID; readonly location?: Location.Ref }
|
||||
| { readonly id?: SessionSchema.ID; readonly location: Location.Ref }
|
||||
)
|
||||
|
||||
/** Creates/adopts durable facts, then binds a private ready instance to the caller's Scope. */
|
||||
export const create = Effect.fn("DirectSession.create")(function* <
|
||||
const Items extends Shared.Replacements = readonly [],
|
||||
>(options: Options<Items>) {
|
||||
const shared = yield* Shared.Service
|
||||
const discovery = options.discovery ?? false
|
||||
const session =
|
||||
options.location === undefined
|
||||
? yield* options.id === undefined
|
||||
? Effect.die(new Error("DirectSession.create requires a location or an existing Session ID"))
|
||||
: shared.sessions.get(options.id)
|
||||
: yield* shared.sessions.create({
|
||||
id: options.id,
|
||||
location: options.location,
|
||||
title: options.title,
|
||||
agent: options.agent,
|
||||
model: options.model,
|
||||
metadata: options.metadata,
|
||||
discovery,
|
||||
})
|
||||
// The backing provider may close before the caller's Scope.
|
||||
const scope = yield* Scope.fork(shared.scope)
|
||||
yield* Effect.addFinalizer((exit) => Scope.close(scope, exit))
|
||||
return yield* Effect.gen(function* () {
|
||||
const binding = yield* shared.bindings.reserve(session.id)
|
||||
const ready = yield* Latch.make()
|
||||
const cell = PluginRuntime.makeCell(ready.await)
|
||||
const replacements: Shared.Replacements = [
|
||||
...shared.replacements,
|
||||
...(options.replacements ?? []),
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
|
||||
]
|
||||
const context = yield* Layer.build(
|
||||
Instance.compose(session.location, { ...options, discovery, replacements }),
|
||||
).pipe(Effect.provideContext(shared.globals))
|
||||
const location = Context.get(context, Location.Service)
|
||||
const info = new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
const bound = <A, E>(effect: Effect.Effect<A, E>) => binding.check.pipe(Effect.orDie, Effect.andThen(effect))
|
||||
const at = <A, E>(ref: Location.Ref, effect: Effect.Effect<A, E>) =>
|
||||
bound(
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
? effect
|
||||
: Effect.die(new Error("Direct instances can only inspect their bound Location")),
|
||||
)
|
||||
cell.runtime = {
|
||||
session: {
|
||||
...shared.sessions,
|
||||
create: (input) => bound(shared.sessions.create({ ...input, discovery })),
|
||||
prompt: (input) => bound(shared.sessions.prompt(input)),
|
||||
synthetic: (input) => bound(shared.sessions.synthetic(input)),
|
||||
command: (input) => bound(shared.sessions.command(input)),
|
||||
generate: (input) => bound(shared.sessions.generate(input)),
|
||||
rename: (input) => bound(shared.sessions.rename(input)),
|
||||
move: (input) => bound(shared.sessions.move(input)),
|
||||
switchAgent: (input) => bound(shared.sessions.switchAgent(input)),
|
||||
switchModel: (input) => bound(shared.sessions.switchModel(input)),
|
||||
},
|
||||
job: shared.jobs,
|
||||
persistentPty: shared.persistentPty,
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) =>
|
||||
at(
|
||||
ref,
|
||||
Context.get(context, Agent.Service)
|
||||
.list()
|
||||
.pipe(Effect.map((data) => ({ location: info, data }))),
|
||||
),
|
||||
},
|
||||
mcp: {
|
||||
list: (ref) =>
|
||||
at(
|
||||
ref,
|
||||
Context.get(context, Mcp.Service)
|
||||
.servers()
|
||||
.pipe(Effect.map((data) => ({ location: info, data }))),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* binding.activate(context)
|
||||
yield* ready.open
|
||||
const observations = yield* Scope.fork(scope)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
binding.shutdown(shared.execution).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
cell.runtime = undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Context.get(context, PluginSupervisor.Service).flush
|
||||
yield* Context.get(context, McpTool.Service).flush
|
||||
|
||||
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
binding.check.pipe(Effect.andThen(effect.pipe(Effect.forkIn(scope))), Effect.flatMap(Fiber.join))
|
||||
const bus = Context.get(shared.globals, Bus.Service)
|
||||
return {
|
||||
id: session.id,
|
||||
prompt: (input: Omit<Parameters<Session.Interface["prompt"]>[0], "sessionID">) =>
|
||||
run(shared.sessions.prompt({ ...input, sessionID: session.id })),
|
||||
resume: () => run(shared.sessions.resume(session.id)),
|
||||
interrupt: () => run(shared.sessions.interrupt(session.id)),
|
||||
wait: () => run(shared.sessions.wait(session.id)),
|
||||
events: {
|
||||
subscribe: <E, R>(callback: (event: SessionEvent.Event) => Effect.Effect<void, E, R>) =>
|
||||
binding.check.pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const caller = yield* Scope.Scope
|
||||
const observer = yield* Scope.fork(observations)
|
||||
const events = yield* bus.observe(session.id).pipe(Scope.provide(observer))
|
||||
return yield* events.pipe(
|
||||
Stream.runForEach(callback),
|
||||
Scope.provide(observer),
|
||||
Effect.onExit((exit) => Scope.close(observer, exit)),
|
||||
Effect.forkIn(observer),
|
||||
Effect.map(Fiber.runIn(caller)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
}).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void)),
|
||||
)
|
||||
})
|
||||
|
||||
export type Handle = Effect.Success<ReturnType<typeof create>>
|
||||
@@ -4,7 +4,7 @@ import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Job } from "../job.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { SessionInstance } from "./instance.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionRunCoordinator } from "./run-coordinator.js"
|
||||
@@ -31,9 +31,11 @@ export interface Interface {
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Settles a scoped instance's work without releasing its restart claim. */
|
||||
readonly shutdown: (sessionIDs: readonly SessionSchema.ID[]) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
/** Routes execution from a Session ID to its host-selected instance's runner. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
|
||||
|
||||
type InterruptReason = "user" | "shutdown"
|
||||
@@ -46,12 +48,12 @@ export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?:
|
||||
return { type: "failed" as const, error: toSessionError(failure) }
|
||||
}
|
||||
|
||||
/** Process-local execution: drains run in this process, routed through the Session's Location graph. */
|
||||
/** One process-local coordinator; instance selection is separate from its drain policy. */
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const instances = yield* SessionInstance.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -88,7 +90,7 @@ export const layer = Layer.effect(
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.provide(instances.get(session)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
@@ -160,9 +162,17 @@ export const layer = Layer.effect(
|
||||
yield* coordinator.wake(sessionID, "steer")
|
||||
return interrupted
|
||||
}),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
resume: (sessionID) => instances.check(sessionID).pipe(Effect.andThen(coordinator.run(sessionID))),
|
||||
wake: (sessionID) => instances.check(sessionID).pipe(Effect.andThen(coordinator.wake(sessionID))),
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
shutdown: (sessionIDs) =>
|
||||
coordinator
|
||||
.interruptAll(sessionIDs, "shutdown")
|
||||
.pipe(
|
||||
Effect.andThen(
|
||||
Effect.forEach(sessionIDs, coordinator.awaitIdle, { concurrency: "unbounded", discard: true }),
|
||||
),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -170,7 +180,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
|
||||
deps: [SessionStore.node, SessionInstance.node, Bus.node, Database.node, Job.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
@@ -182,5 +192,6 @@ export const noopLayer = Layer.succeed(
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
shutdown: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Project } from "../project.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionTable } from "./sql.js"
|
||||
import type { SessionTable } from "./sql.js"
|
||||
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export * as SessionInstance from "./instance.js"
|
||||
|
||||
import { Context, Effect, Layer, RcMap } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Instance } from "../instance.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
|
||||
/** Selects capabilities without owning Session admission or execution coordination. */
|
||||
export interface Interface {
|
||||
readonly get: (session: SessionSchema.Info) => Layer.Layer<Instance.Services, Instance.Error>
|
||||
readonly check: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly closeTransport: (session: SessionSchema.Info) => Effect.Effect<void>
|
||||
readonly destination: (ref: Location.Ref) => Layer.Layer<Location.Service, Instance.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionInstance") {}
|
||||
|
||||
/** The server keeps sharing a graph for each canonical Location. */
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
return Service.of({
|
||||
get: (session) => locations.get(session.location),
|
||||
check: () => Effect.void,
|
||||
destination: (ref) => locations.get(ref),
|
||||
closeTransport: Effect.fn("SessionInstance.closeTransport")(function* (session) {
|
||||
const ref = Location.Ref.make({
|
||||
directory: session.location.directory,
|
||||
workspaceID: session.location.workspaceID,
|
||||
})
|
||||
if (!(yield* RcMap.has(locations.rcMap, ref))) return
|
||||
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
|
||||
Effect.provide(locations.get(ref)),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [LocationServiceMap.node] })
|
||||
@@ -4,21 +4,17 @@ import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getShell: (
|
||||
shellID: SessionMessage.Shell["shellID"],
|
||||
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"]>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getShell: (shellID: SessionMessage.Shell["shellID"]) => Effect.Effect<SessionMessage.Shell | undefined>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
||||
@@ -38,16 +34,14 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
|
||||
const created = DateTime.makeUnsafe(event.created)
|
||||
|
||||
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
|
||||
assistant?.content.findLast(
|
||||
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
|
||||
)
|
||||
const latestTool = (assistant: DraftAssistant, id: string) =>
|
||||
assistant.content.findLast((item): item is DraftTool => item.type === "tool" && item.id === id)
|
||||
|
||||
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||
const latestText = (assistant: DraftAssistant) =>
|
||||
assistant.content.findLast((item): item is DraftText => item.type === "text")
|
||||
|
||||
const latestReasoning = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||
const latestReasoning = (assistant: DraftAssistant) =>
|
||||
assistant.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||
|
||||
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -6,13 +6,13 @@ import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionMessageUpdater } from "./message-updater.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
@@ -26,8 +26,10 @@ import type { SessionSchema } from "./schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
type MessageEvent = Exclude<
|
||||
SessionEvent.DurableEvent,
|
||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
|
||||
>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
@@ -53,16 +55,16 @@ const forkTitle = (value?: string) => {
|
||||
return `${value} (fork #1)`
|
||||
}
|
||||
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage) {
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
|
||||
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
|
||||
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
|
||||
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
|
||||
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
|
||||
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
|
||||
cost: sql`${SessionTable.cost} + ${value.cost}`,
|
||||
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input}`,
|
||||
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output}`,
|
||||
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning}`,
|
||||
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read}`,
|
||||
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write}`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
@@ -73,7 +75,7 @@ function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usa
|
||||
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
export * as PromptCacheDiagnostics from "./prompt-cache-diagnostics.js"
|
||||
|
||||
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}]`,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
* interrupted. Compose with `awaitIdle` for settlement.
|
||||
*/
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
|
||||
/** Marks the whole ownership chain before signaling any of its fibers. */
|
||||
readonly interruptAll: (keys: Iterable<Key>, reason?: Reason) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
@@ -135,28 +137,42 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
start(key, false, scope)
|
||||
})
|
||||
|
||||
const stop = (key: Key, reason?: Reason) => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined || execution.stopping) return undefined
|
||||
if (execution.owner === undefined) {
|
||||
// Settlement window: the owner exited but the settled hook has not finished. The
|
||||
// terminal outcome is already decided, so no reason attaches — but the interrupt
|
||||
// still claims the recorded wakes so settle does not start a dead-intent successor.
|
||||
execution.pendingWake = undefined
|
||||
return undefined
|
||||
}
|
||||
execution.stopping = true
|
||||
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
|
||||
// Wakes arriving during cleanup are new admissions and restart normally at settle.
|
||||
execution.pendingWake = undefined
|
||||
execution.interruptionReason = reason
|
||||
return execution.owner
|
||||
}
|
||||
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<boolean> =>
|
||||
Effect.sync(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined || execution.stopping) return false
|
||||
if (execution.owner === undefined) {
|
||||
// Settlement window: the owner exited but the settled hook has not finished. The
|
||||
// terminal outcome is already decided, so no reason attaches — but the interrupt
|
||||
// still claims the recorded wakes so settle does not start a dead-intent successor.
|
||||
execution.pendingWake = undefined
|
||||
return false
|
||||
}
|
||||
execution.stopping = true
|
||||
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
|
||||
// Wakes arriving during cleanup are new admissions and restart normally at settle.
|
||||
execution.pendingWake = undefined
|
||||
execution.interruptionReason = reason
|
||||
const owner = stop(key, reason)
|
||||
if (owner === undefined) return false
|
||||
// Fire and forget: nobody benefits from waiting out cleanup here, and callers like
|
||||
// the interrupt endpoint must acknowledge immediately even when finalizers are slow.
|
||||
fork(Fiber.interrupt(execution.owner))
|
||||
fork(Fiber.interrupt(owner))
|
||||
return true
|
||||
})
|
||||
|
||||
const interruptAll = (keys: Iterable<Key>, reason?: Reason) =>
|
||||
Effect.sync(() => {
|
||||
Array.from(keys)
|
||||
.map((key) => stop(key, reason))
|
||||
.filter((owner) => owner !== undefined)
|
||||
.forEach((owner) => fork(Fiber.interrupt(owner)))
|
||||
})
|
||||
|
||||
// One execution's `done` already spans coalesced continuations; re-check after it
|
||||
// settles to cover a successor execution started by a late doorbell.
|
||||
const awaitIdle = (key: Key): Effect.Effect<void> =>
|
||||
@@ -166,5 +182,5 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, interruptAll, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { Cause, Config, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { InstructionState } from "../instruction-state.js"
|
||||
@@ -24,9 +24,7 @@ import { SessionRunnerRetry } from "./retry.js"
|
||||
import { SessionStep } from "./step.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { PluginSupervisor } from "../../plugin/supervisor.js"
|
||||
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps.js"
|
||||
import { PluginExecution } from "../../plugin/execution.js"
|
||||
|
||||
const CONTINUE_AFTER_INCOMPLETE_STREAM =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
@@ -41,35 +39,8 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const pluginExecution = yield* PluginExecution.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const steps = yield* SessionStep.make
|
||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const promptCacheSnapshots = diagnostics ? new Map<string, PromptCacheDiagnostics.Snapshot>() : undefined
|
||||
const diagnosePromptCache = Effect.fn("SessionRunner.diagnosePromptCache")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
request: Parameters<typeof PromptCacheDiagnostics.snapshot>[0],
|
||||
) {
|
||||
if (!promptCacheSnapshots) return
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(sessionID), current)
|
||||
promptCacheSnapshots.delete(sessionID)
|
||||
promptCacheSnapshots.set(sessionID, current)
|
||||
const oldest = promptCacheSnapshots.keys().next().value
|
||||
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
||||
yield* Effect.logInfo("prompt cache prefix").pipe(
|
||||
Effect.annotateLogs({
|
||||
sessionID,
|
||||
toolCount: current.tools.length,
|
||||
systemParts: current.system.length,
|
||||
messageCount: current.messages.length,
|
||||
...comparison,
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
|
||||
|
||||
@@ -91,6 +62,7 @@ const layer = Layer.effect(
|
||||
const control = pending.type === "compaction" || pending.type === "move"
|
||||
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
|
||||
}
|
||||
yield* plugins.flush
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
|
||||
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
|
||||
@@ -244,14 +216,12 @@ const layer = Layer.effect(
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
})
|
||||
yield* diagnosePromptCache(sessionID, prepared.request)
|
||||
const outcome = yield* steps.attempt({
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: loaded.agent.id,
|
||||
model: loaded.model,
|
||||
prepared,
|
||||
toolsDisabled: stepLimitReached,
|
||||
recoverContinuation,
|
||||
recoverOverflow: Effect.suspend(() =>
|
||||
recoverOverflow && compaction.enabled()
|
||||
@@ -314,14 +284,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
drain: Effect.fnUntraced(function* (input) {
|
||||
yield* plugins.initialized
|
||||
const session = yield* store.get(input.sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${input.sessionID}`))
|
||||
return yield* pluginExecution.lease(session, drain(input))
|
||||
}),
|
||||
})
|
||||
return Service.of({ drain })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -340,6 +303,5 @@ export const node = makeLocationNode({
|
||||
Snapshot.node,
|
||||
ToolOutput.node,
|
||||
Database.node,
|
||||
PluginExecution.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -396,7 +396,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* text.append(event.id, event.text, providerState(event.providerMetadata))
|
||||
return
|
||||
case "text-end":
|
||||
yield* text.end(event.id, providerState(event.providerMetadata))
|
||||
yield* text.end(event.id, providerState(event.providerMetadata), event.text)
|
||||
return
|
||||
case "reasoning-start":
|
||||
outputStarted = true
|
||||
@@ -412,7 +412,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* reasoning.append(event.id, event.text, providerState(event.providerMetadata))
|
||||
return
|
||||
case "reasoning-end":
|
||||
yield* reasoning.end(event.id, providerState(event.providerMetadata))
|
||||
yield* reasoning.end(event.id, providerState(event.providerMetadata), event.text)
|
||||
return
|
||||
case "tool-input-start":
|
||||
outputStarted = true
|
||||
|
||||
@@ -26,12 +26,16 @@ export function isRetryable(error: AIError) {
|
||||
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
// Unrecognized failures retry: classification records affirmative
|
||||
// deterministic evidence, and transient failures are exactly the ones
|
||||
// that arrive in shapes no classifier anticipates.
|
||||
case "UnknownProvider":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidRequest":
|
||||
case "NoRoute":
|
||||
case "UnknownProvider":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
|
||||
@@ -44,7 +44,6 @@ interface Input {
|
||||
readonly agent: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly prepared: SessionModelRequest.Prepared
|
||||
readonly toolsDisabled: boolean
|
||||
readonly recoverContinuation: boolean
|
||||
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
|
||||
readonly recoverOverflow: Effect.Effect<boolean>
|
||||
@@ -73,11 +72,12 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
const toolRuns: Array<{
|
||||
readonly call: ToolCall
|
||||
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
|
||||
readonly fiber: Fiber.Fiber<void, Permission.DeclinedError | QuestionTool.CancelledError>
|
||||
}> = []
|
||||
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
|
||||
const executeTool = (call: ToolCall) => {
|
||||
if (input.toolsDisabled) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
if (input.prepared.request.toolChoice?.type === "none")
|
||||
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
return input.prepared.executeTool({
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
@@ -132,10 +132,7 @@ export const make = Effect.gen(function* () {
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
|
||||
if (Exit.isFailure(joined)) yield* interruptTools
|
||||
const tools = classifyToolExits(
|
||||
joined,
|
||||
toolRuns.map((run) => run.call),
|
||||
)
|
||||
const tools = classifyToolExits(joined, toolRuns)
|
||||
|
||||
if (
|
||||
!publisher.record().outputStarted &&
|
||||
@@ -240,7 +237,7 @@ export const make = Effect.gen(function* () {
|
||||
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
return Outcome.Completed({
|
||||
needsContinuation: !input.toolsDisabled && record.needsContinuation,
|
||||
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -249,27 +246,22 @@ export const make = Effect.gen(function* () {
|
||||
return { attempt }
|
||||
})
|
||||
|
||||
const isDecline = (
|
||||
error: SessionModelRequest.ExecuteError,
|
||||
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
|
||||
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
|
||||
|
||||
const isInterruptedStream = (failure: AIError) => {
|
||||
if (failure.reason._tag === "InvalidProviderOutput") return failure.reason.classification === "incomplete-stream"
|
||||
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
|
||||
return false
|
||||
}
|
||||
|
||||
/** Keep every joined exit associated with its call; a decline is not an infrastructure failure. */
|
||||
/** Tool.Error settles in each fiber; only user declines remain in the typed error channel. */
|
||||
const classifyToolExits = (
|
||||
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>>,
|
||||
calls: ReadonlyArray<ToolCall>,
|
||||
settled: Exit.Exit<Array<Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>>>,
|
||||
runs: ReadonlyArray<{ readonly call: ToolCall }>,
|
||||
) => {
|
||||
const exits = Exit.isSuccess(settled) ? settled.value : []
|
||||
const declines = exits.flatMap((exit, index) =>
|
||||
Exit.isFailure(exit)
|
||||
? exit.cause.reasons.flatMap((reason) =>
|
||||
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
|
||||
Cause.isFailReason(reason) ? [{ call: runs[index].call, reason: reason.error }] : [],
|
||||
)
|
||||
: [],
|
||||
)
|
||||
@@ -279,11 +271,8 @@ const classifyToolExits = (
|
||||
const failure = causes
|
||||
.flatMap((cause) => {
|
||||
if (Cause.hasInterrupts(cause)) return []
|
||||
const reasons = cause.reasons.flatMap(
|
||||
(reason): Array<Cause.Reason<never>> =>
|
||||
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeDieReason(reason.error)]) : [reason],
|
||||
)
|
||||
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
|
||||
const reasons = cause.reasons.filter(Cause.isDieReason)
|
||||
return reasons.length > 0 ? [Cause.fromReasons<never>(reasons)] : []
|
||||
})
|
||||
.at(0)
|
||||
return { interrupted: causes.some(Cause.hasInterrupts), declines, failure }
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ProjectTable } from "../project/sql.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionInbox } from "./inbox.js"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { PermissionV1 } from "../v1/permission.js"
|
||||
import { Project } from "../project.js"
|
||||
import type { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||
import type { Project } from "@opencode-ai/schema/project"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Timestamps } from "../database/schema.sql.js"
|
||||
import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
@@ -255,7 +255,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
Effect.tap((rows) =>
|
||||
Effect.sync(() => {
|
||||
rows.forEach((row) => {
|
||||
addToolStatus(toolTotals, row.status, 1)
|
||||
addToolStatus(toolTotals, row.status)
|
||||
if (!row.name) return
|
||||
const tool = tools.get(row.name) ?? {
|
||||
name: row.name,
|
||||
@@ -266,7 +266,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
durations: [],
|
||||
}
|
||||
tools.set(row.name, tool)
|
||||
addToolStatus(tool, row.status, 1)
|
||||
addToolStatus(tool, row.status)
|
||||
if (row.duration !== null) tool.durations.push(row.duration)
|
||||
})
|
||||
}),
|
||||
@@ -385,18 +385,17 @@ function tokenTotal(tokens: Tokens) {
|
||||
function addToolStatus(
|
||||
target: { calls: number; succeeded: number; failed: number; unfinished: number },
|
||||
status: string | null,
|
||||
count: number,
|
||||
) {
|
||||
target.calls += count
|
||||
target.calls++
|
||||
if (status === "completed") {
|
||||
target.succeeded += count
|
||||
target.succeeded++
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
target.failed += count
|
||||
target.failed++
|
||||
return
|
||||
}
|
||||
target.unfinished += count
|
||||
target.unfinished++
|
||||
}
|
||||
|
||||
function makeDateKey(timezone = "UTC") {
|
||||
|
||||
+105
-133
@@ -1,8 +1,7 @@
|
||||
export * as SessionTitle from "./title.js"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { LLMClient, LLMEvent, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import type { Agent } from "../agent.js"
|
||||
import { Database } from "../database/database.js"
|
||||
@@ -17,22 +16,12 @@ import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
import { PluginExecution } from "../plugin/execution.js"
|
||||
|
||||
const MAX_LENGTH = 100
|
||||
const MAX_CONTEXT_LENGTH = 8_000
|
||||
const MAX_FIRST_MESSAGE_LENGTH = 2_000
|
||||
const titleChanged = Symbol("Session title changed")
|
||||
|
||||
type Dependencies = {
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly context: SessionContext.Interface
|
||||
readonly store: SessionStore.Interface
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Generates an initial title or regenerates one from bounded conversation history. */
|
||||
readonly generate: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
@@ -47,117 +36,6 @@ export const isUntitled = (session: SessionSchema.Info) =>
|
||||
time: { created: DateTime.toEpochMillis(session.time.created) },
|
||||
})
|
||||
|
||||
const attempt = Effect.fn("SessionTitle.attempt")(function* (
|
||||
dependencies: Dependencies,
|
||||
input: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agent: Agent.Info
|
||||
readonly text: string
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
},
|
||||
) {
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: input.session.id,
|
||||
source: "title",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* dependencies.context.prepare({
|
||||
scope: { session: input.session, agentID: input.agent.id, model: input.model },
|
||||
transcript: {
|
||||
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
|
||||
messages: [Message.user(input.text)],
|
||||
},
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, input.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", () =>
|
||||
Effect.sync(() => {
|
||||
failed = true
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
||||
)
|
||||
yield* recordUsage
|
||||
if (failed) return
|
||||
return chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
})
|
||||
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const generate = Effect.fn("SessionTitle.generate")(function* (
|
||||
db: Database.Interface["db"],
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const session = yield* dependencies.store.get(sessionID)
|
||||
if (!session) return
|
||||
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
|
||||
if (!firstUser) return
|
||||
const text = !isUntitled(session)
|
||||
? yield* dependencies.store.context(session.id).pipe(
|
||||
Effect.map((messages) => {
|
||||
const original = `Original request:\n${firstUser.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}`
|
||||
const recent = messages
|
||||
.flatMap((message) => {
|
||||
if (message.type === "user" && message.id !== firstUser.id) return [`User: ${message.text.trim()}`]
|
||||
if (message.type !== "assistant") return []
|
||||
const text = message.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text.trim()] : []))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
return text ? [`Assistant: ${text}`] : []
|
||||
})
|
||||
.join("\n\n")
|
||||
if (!recent) return original
|
||||
const prefix = `${original}\n\nRecent conversation:\n`
|
||||
return `${prefix}${recent.slice(-(MAX_CONTEXT_LENGTH - prefix.length))}`
|
||||
}),
|
||||
Effect.orElseSucceed(() => firstUser.text),
|
||||
)
|
||||
: firstUser.text
|
||||
const selection = yield* dependencies.context.selectTitle(session)
|
||||
if (!selection) return
|
||||
const title =
|
||||
(yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.selected })) ??
|
||||
(selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref)
|
||||
? yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.primary })
|
||||
: undefined)
|
||||
if (!title) return
|
||||
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
|
||||
const current = yield* dependencies.store.get(sessionID)
|
||||
if (!current || current.title !== session.title || current.title === truncate(title)) return
|
||||
yield* dependencies.bus
|
||||
.publish(
|
||||
SessionEvent.Renamed,
|
||||
{
|
||||
sessionID: session.id,
|
||||
title: truncate(title),
|
||||
},
|
||||
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
|
||||
)
|
||||
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
|
||||
})
|
||||
return { generate }
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -165,21 +43,115 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const context = yield* SessionContext.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const database = yield* Database.Service
|
||||
const gate = yield* PluginExecution.Service
|
||||
const title = make({ bus, llm, context, store })
|
||||
return Service.of({
|
||||
generate: Effect.fnUntraced(function* (sessionID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return
|
||||
return yield* gate.lease({ id: session.id, admission: true }, title.generate(database.db, sessionID))
|
||||
}),
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const attempt = Effect.fn("SessionTitle.attempt")(function* (input: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agent: Agent.Info
|
||||
readonly text: string
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
}) {
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: input.session.id,
|
||||
source: "title",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* context.prepare({
|
||||
scope: { session: input.session, agentID: input.agent.id, model: input.model },
|
||||
transcript: {
|
||||
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
|
||||
messages: [Message.user(input.text)],
|
||||
},
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, input.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", () =>
|
||||
Effect.sync(() => {
|
||||
failed = true
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
||||
)
|
||||
yield* recordUsage
|
||||
if (failed) return
|
||||
return chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
})
|
||||
|
||||
const generate = Effect.fn("SessionTitle.generate")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return
|
||||
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
|
||||
if (!firstUser) return
|
||||
const text = !isUntitled(session)
|
||||
? yield* store.context(session.id).pipe(
|
||||
Effect.map((messages) => {
|
||||
const original = `Original request:\n${firstUser.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}`
|
||||
const recent = messages
|
||||
.flatMap((message) => {
|
||||
if (message.type === "user" && message.id !== firstUser.id) return [`User: ${message.text.trim()}`]
|
||||
if (message.type !== "assistant") return []
|
||||
const text = message.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text.trim()] : []))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
return text ? [`Assistant: ${text}`] : []
|
||||
})
|
||||
.join("\n\n")
|
||||
if (!recent) return original
|
||||
const prefix = `${original}\n\nRecent conversation:\n`
|
||||
return `${prefix}${recent.slice(-(MAX_CONTEXT_LENGTH - prefix.length))}`
|
||||
}),
|
||||
Effect.orElseSucceed(() => firstUser.text),
|
||||
)
|
||||
: firstUser.text
|
||||
const selection = yield* context.selectTitle(session)
|
||||
if (!selection) return
|
||||
const title =
|
||||
(yield* attempt({ session, agent: selection.agent, text, model: selection.selected })) ??
|
||||
(selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref)
|
||||
? yield* attempt({ session, agent: selection.agent, text, model: selection.primary })
|
||||
: undefined)
|
||||
if (!title) return
|
||||
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
|
||||
const current = yield* store.get(sessionID)
|
||||
if (!current || current.title !== session.title || current.title === truncate(title)) return
|
||||
yield* bus
|
||||
.publish(
|
||||
SessionEvent.Renamed,
|
||||
{
|
||||
sessionID: session.id,
|
||||
title: truncate(title),
|
||||
},
|
||||
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
|
||||
)
|
||||
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
|
||||
})
|
||||
return Service.of({ generate })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node, PluginExecution.node],
|
||||
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node],
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionUsage from "./usage.js"
|
||||
import type { Usage } from "@opencode-ai/ai"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import type { Model } from "../model.js"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
|
||||
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
|
||||
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
export * as Shared from "./shared.js"
|
||||
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { App } from "./app.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Job } from "./job.js"
|
||||
import { PersistentPty } from "./persistent-pty.js"
|
||||
import { Session } from "./session.js"
|
||||
import { SessionBindings } from "./session/bindings.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionInstance } from "./session/instance.js"
|
||||
|
||||
export interface Options<Items extends Replacements = Replacements> {
|
||||
readonly database?: Database.Options
|
||||
readonly app?: Partial<App.Info>
|
||||
readonly replacements?: LayerNode.ComposableReplacements<Items>
|
||||
}
|
||||
|
||||
/** Ready constructors accept only fully wired, infallible replacements. */
|
||||
export type Replacements = readonly (readonly [
|
||||
LayerNode.Node<unknown, unknown, LayerNode.Tag | undefined>,
|
||||
LayerNode.Node<unknown, never, LayerNode.Tag | undefined> | Layer.Layer<never>,
|
||||
])[]
|
||||
|
||||
export interface Interface {
|
||||
readonly scope: Scope.Scope
|
||||
readonly globals: Context.Context<Instance.Globals>
|
||||
readonly sessions: Session.Interface
|
||||
readonly bindings: SessionBindings.Interface
|
||||
readonly execution: SessionExecution.Interface
|
||||
readonly jobs: Job.Interface
|
||||
readonly persistentPty: PersistentPty.Interface
|
||||
readonly replacements: Replacements
|
||||
}
|
||||
|
||||
/** Supplied once by the host; contains no location map or embedded HTTP server. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Shared") {}
|
||||
|
||||
export function layer<const Items extends Replacements = readonly []>(options: Options<Items> = {}) {
|
||||
const replacements: Replacements = [
|
||||
[
|
||||
Database.node,
|
||||
Database.configured({
|
||||
path:
|
||||
options.database?.path && options.database.path !== ":memory:"
|
||||
? path.resolve(options.database.path)
|
||||
: ":memory:",
|
||||
}),
|
||||
],
|
||||
[App.node, App.configured(options.app)],
|
||||
...(options.replacements ?? []),
|
||||
[SessionInstance.node, SessionBindings.instanceNode],
|
||||
]
|
||||
const configured: LayerNode.Replacements = replacements
|
||||
return LayerNode.compile(
|
||||
LayerNode.group([
|
||||
Instance.globalsGraph,
|
||||
Session.node,
|
||||
SessionBindings.node,
|
||||
SessionExecution.node,
|
||||
Job.node,
|
||||
PersistentPty.node,
|
||||
]),
|
||||
configured,
|
||||
).pipe(
|
||||
Layer.flatMap((context) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.map(Scope.Scope, (scope) => ({
|
||||
scope,
|
||||
globals: context,
|
||||
sessions: Context.get(context, Session.Service),
|
||||
bindings: Context.get(context, SessionBindings.Service),
|
||||
execution: Context.get(context, SessionExecution.Service),
|
||||
jobs: Context.get(context, Job.Service),
|
||||
persistentPty: Context.get(context, PersistentPty.Service),
|
||||
replacements,
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ const cleanupLayer = Layer.effectDiscard(
|
||||
cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped),
|
||||
)
|
||||
|
||||
const cleanupNode = makeGlobalNode({
|
||||
export const cleanupNode = makeGlobalNode({
|
||||
name: "shell-output-cleanup",
|
||||
layer: cleanupLayer,
|
||||
deps: [FSUtil.node, Global.node],
|
||||
|
||||
@@ -142,7 +142,7 @@ const cleanupLayer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
const cleanupNode = makeGlobalNode({
|
||||
export const cleanupNode = makeGlobalNode({
|
||||
name: "tool-output-cleanup",
|
||||
layer: cleanupLayer,
|
||||
deps: [FSUtil.node, Global.node],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export * as EditTool from "./edit.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
@@ -108,7 +108,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as GlobTool from "./glob.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
@@ -41,7 +41,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
||||
/** Glob leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.glob",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: Context) {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as GrepTool from "./grep.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
@@ -57,7 +57,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
|
||||
/** Grep leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.grep",
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: Context) {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PatchTool from "./patch.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
@@ -65,7 +65,7 @@ type Prepared =
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as QuestionTool from "./question.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../../form.js"
|
||||
@@ -47,7 +47,7 @@ export const toModelContent = (questions: ReadonlyArray<Question.Prompt>, answer
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.question",
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: Context) {
|
||||
const forms = yield* Form.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ReadTool from "./read.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { basename, dirname, join } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -29,7 +29,7 @@ const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ShellTool from "./shell.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
@@ -19,7 +19,7 @@ export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
|
||||
const BACKGROUND_INSTRUCTION =
|
||||
"You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly."
|
||||
"You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response."
|
||||
const OS =
|
||||
process.platform === "darwin"
|
||||
? "macOS"
|
||||
@@ -102,7 +102,7 @@ const backgroundResult = (shellID: string, file: string) => ({
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.shell",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: Context) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const environment = yield* Environment.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SkillTool from "./skill.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -31,7 +31,7 @@ const unableToLoad = (name: string, error?: unknown) =>
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.skill",
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: Context) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const skills = yield* Skill.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SubagentTool from "./subagent.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
@@ -52,7 +52,7 @@ export const description = [
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.subagent",
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: Context) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebFetchTool from "./webfetch.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, type HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
@@ -102,7 +102,7 @@ const convert = (content: string, contentType: string, format: Format) => {
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.webfetch",
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: Context) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user