mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67c0c79613 | |||
| 603fa77705 | |||
| 3898cac63d | |||
| 2fee823dbf | |||
| 334613c18f | |||
| aa2c1472fa | |||
| cfdb9ab705 | |||
| 67b4b2894f | |||
| bf5294121a | |||
| 7d598b5610 | |||
| fae51d853d | |||
| 966dc54e1d | |||
| 606de48d8b | |||
| 0d1ef5adb6 | |||
| f016392368 | |||
| 140224b0fc | |||
| cfd35c9354 | |||
| 674d08f9be | |||
| 02f012f5d1 | |||
| bea6e1499d | |||
| 50a1fe49bc | |||
| 6380919fda | |||
| d972aa9d83 | |||
| d544ab4d91 | |||
| ce228bfd7c | |||
| eabf85aea2 | |||
| 2f4a688790 | |||
| 4a90ffedfb | |||
| 95cf5039be | |||
| 041215996c | |||
| d0b920e187 |
@@ -0,0 +1,36 @@
|
||||
export default {
|
||||
id: "Orchestrator",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("orchestrator", (agent) => {
|
||||
agent.description = "Coordinates work by delegating implementation tasks to the minion subagent."
|
||||
agent.mode = "primary"
|
||||
agent.system = [
|
||||
"You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.",
|
||||
"Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.",
|
||||
"You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.",
|
||||
"Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.",
|
||||
"Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.",
|
||||
"Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.",
|
||||
"Synthesize minion results, decide next steps, and report back concisely.",
|
||||
].join("\n")
|
||||
})
|
||||
|
||||
agents.update("minion", (agent) => {
|
||||
agent.description = "Subagent that executes focused tasks delegated by Orchestrator."
|
||||
agent.mode = "subagent"
|
||||
agent.model = { providerID: "opencode", id: "glm-5.2" }
|
||||
agent.system = [
|
||||
"You are minion, a focused execution subagent for this repository.",
|
||||
"Complete the specific task delegated to you by Orchestrator using the available tools.",
|
||||
"Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.",
|
||||
"Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.",
|
||||
"If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.",
|
||||
"Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.",
|
||||
"Do not delegate to other subagents; execute the assigned work yourself.",
|
||||
].join("\n")
|
||||
agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" })
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
id: "sample-agent-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("sample-plugin-agent", (agent) => {
|
||||
agent.description = "Example primary agent registered by .opencode/plugins/sample-agent.ts"
|
||||
agent.mode = "primary"
|
||||
agent.system = [
|
||||
"You are the sample plugin agent for this repository.",
|
||||
"Use this agent to verify that local plugin auto-discovery can add agents.",
|
||||
"Keep responses concise and explain which plugin registered you when asked.",
|
||||
].join("\n")
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -159,4 +159,5 @@ const table = sqliteTable("session", {
|
||||
- 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. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
- Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry.
|
||||
- The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline.
|
||||
|
||||
@@ -164,23 +164,36 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
|
||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||
type Endpoint4_10Input = {
|
||||
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
|
||||
readonly text: Endpoint4_10Request["payload"]["text"]
|
||||
readonly description?: Endpoint4_10Request["payload"]["description"]
|
||||
readonly metadata?: Endpoint4_10Request["payload"]["metadata"]
|
||||
}
|
||||
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.synthetic"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
|
||||
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
|
||||
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint4_12Input = {
|
||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_12Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_12Request["payload"]["files"]
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint4_13Input = {
|
||||
readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_13Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_13Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
||||
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -189,42 +202,42 @@ const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12I
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
||||
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
||||
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint4_16Input = {
|
||||
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint4_16Request["query"]["limit"]
|
||||
readonly after?: Endpoint4_16Request["query"]["after"]
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint4_17Input = {
|
||||
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint4_17Request["query"]["limit"]
|
||||
readonly after?: Endpoint4_17Request["query"]["after"]
|
||||
}
|
||||
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
||||
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
||||
raw["session.history"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint4_17Input = {
|
||||
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_17Request["query"]["after"]
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint4_18Input = {
|
||||
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_18Request["query"]["after"]
|
||||
}
|
||||
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
@@ -232,22 +245,22 @@ const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17I
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
|
||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_20Input = {
|
||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_20Request["params"]["messageID"]
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_21Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -264,17 +277,18 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
|
||||
rename: Endpoint4_7(raw),
|
||||
prompt: Endpoint4_8(raw),
|
||||
skill: Endpoint4_9(raw),
|
||||
compact: Endpoint4_10(raw),
|
||||
wait: Endpoint4_11(raw),
|
||||
revertStage: Endpoint4_12(raw),
|
||||
revertClear: Endpoint4_13(raw),
|
||||
revertCommit: Endpoint4_14(raw),
|
||||
context: Endpoint4_15(raw),
|
||||
history: Endpoint4_16(raw),
|
||||
events: Endpoint4_17(raw),
|
||||
interrupt: Endpoint4_18(raw),
|
||||
background: Endpoint4_19(raw),
|
||||
message: Endpoint4_20(raw),
|
||||
synthetic: Endpoint4_10(raw),
|
||||
compact: Endpoint4_11(raw),
|
||||
wait: Endpoint4_12(raw),
|
||||
revertStage: Endpoint4_13(raw),
|
||||
revertClear: Endpoint4_14(raw),
|
||||
revertCommit: Endpoint4_15(raw),
|
||||
context: Endpoint4_16(raw),
|
||||
history: Endpoint4_17(raw),
|
||||
events: Endpoint4_18(raw),
|
||||
interrupt: Endpoint4_19(raw),
|
||||
background: Endpoint4_20(raw),
|
||||
message: Endpoint4_21(raw),
|
||||
})
|
||||
|
||||
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
@@ -297,7 +311,12 @@ type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["locat
|
||||
const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) =>
|
||||
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw) })
|
||||
type Endpoint6_1Request = Parameters<RawClient["server.model"]["model.default"]>[0]
|
||||
type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] }
|
||||
const Endpoint6_1 = (raw: RawClient["server.model"]) => (input?: Endpoint6_1Input) =>
|
||||
raw["model.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw), default: Endpoint6_1(raw) })
|
||||
|
||||
type Endpoint7_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
|
||||
type Endpoint7_0Input = {
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
SessionPromptOutput,
|
||||
SessionSkillInput,
|
||||
SessionSkillOutput,
|
||||
SessionSyntheticInput,
|
||||
SessionSyntheticOutput,
|
||||
SessionCompactInput,
|
||||
SessionCompactOutput,
|
||||
SessionWaitInput,
|
||||
@@ -51,6 +53,8 @@ import type {
|
||||
MessageListOutput,
|
||||
ModelListInput,
|
||||
ModelListOutput,
|
||||
ModelDefaultInput,
|
||||
ModelDefaultOutput,
|
||||
GenerateTextInput,
|
||||
GenerateTextOutput,
|
||||
ProviderListInput,
|
||||
@@ -461,6 +465,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
synthetic: (input: SessionSyntheticInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSyntheticOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`,
|
||||
body: { text: input["text"], description: input["description"], metadata: input["metadata"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionCompactOutput>(
|
||||
{
|
||||
@@ -613,6 +629,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
default: (input?: ModelDefaultInput, requestOptions?: RequestOptions) =>
|
||||
request<ModelDefaultOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/model/default`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
generate: {
|
||||
text: (input: GenerateTextInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -151,6 +151,7 @@ export type AgentListOutput = {
|
||||
readonly id: string
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
@@ -584,6 +585,27 @@ export type SessionSkillInput = {
|
||||
|
||||
export type SessionSkillOutput = void
|
||||
|
||||
export type SessionSyntheticInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly text: {
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}["text"]
|
||||
readonly description?: {
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}["description"]
|
||||
readonly metadata?: {
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}["metadata"]
|
||||
}
|
||||
|
||||
export type SessionSyntheticOutput = void
|
||||
|
||||
export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionCompactOutput = void
|
||||
@@ -934,6 +956,7 @@ export type SessionHistoryOutput = {
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1428,6 +1451,7 @@ export type SessionEventsOutput =
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -2169,12 +2193,14 @@ export type ModelListOutput = {
|
||||
readonly output: ReadonlyArray<string>
|
||||
}
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
readonly variant?: string
|
||||
}
|
||||
readonly variants: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}>
|
||||
@@ -2191,6 +2217,67 @@ export type ModelListOutput = {
|
||||
}>
|
||||
}
|
||||
|
||||
export type ModelDefaultInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ModelDefaultOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly providerID: string
|
||||
readonly family?: string
|
||||
readonly name: string
|
||||
readonly api:
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "aisdk"
|
||||
readonly package: string
|
||||
readonly url?: string
|
||||
readonly settings?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "native"
|
||||
readonly url?: string
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
readonly capabilities: {
|
||||
readonly tools: boolean
|
||||
readonly input: ReadonlyArray<string>
|
||||
readonly output: ReadonlyArray<string>
|
||||
}
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
readonly variant?: string
|
||||
}
|
||||
readonly variants: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}>
|
||||
readonly time: { readonly released: number }
|
||||
readonly cost: ReadonlyArray<{
|
||||
readonly tier?: { readonly type: "context"; readonly size: number }
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}>
|
||||
readonly status: "alpha" | "beta" | "deprecated" | "active"
|
||||
readonly enabled: boolean
|
||||
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
|
||||
} | null
|
||||
}
|
||||
|
||||
export type GenerateTextInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -2233,6 +2320,7 @@ export type ProviderListOutput = {
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
@@ -2266,6 +2354,7 @@ export type ProviderGetOutput = {
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
@@ -3505,6 +3594,19 @@ export type EventSubscribeOutput =
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.execution.settled"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly outcome: "success" | "failure" | "interrupted"
|
||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
@@ -3530,6 +3632,7 @@ export type EventSubscribeOutput =
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -85,6 +85,7 @@ const layer = Layer.effect(
|
||||
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||
: model.api
|
||||
const request = {
|
||||
settings: { ...provider.request.settings, ...model.request.settings },
|
||||
headers: { ...provider.request.headers, ...model.request.headers },
|
||||
body: { ...provider.request.body, ...model.request.body },
|
||||
variant: model.request.variant,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { define } from "../../plugin/internal"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-provider",
|
||||
@@ -54,6 +53,7 @@ export const Plugin = define({
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.settings, item.request.settings)
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export const Plugin = define({
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
Object.assign(model.request.settings, config.request.settings)
|
||||
Object.assign(model.request.headers, config.request.headers)
|
||||
Object.assign(model.request.body, config.request.body)
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
@@ -81,11 +82,13 @@ export const Plugin = define({
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.settings, variant.settings)
|
||||
Object.assign(existing.headers, variant.headers)
|
||||
Object.assign(existing.body, variant.body)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ProviderV2 } from "../provider"
|
||||
import { ModelV2 } from "../model"
|
||||
|
||||
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
|
||||
settings: ProviderV2.Settings.pipe(Schema.optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as InstructionContext from "./instruction-context"
|
||||
|
||||
import { Array, Effect, Layer, Schema } from "effect"
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import { isAbsolute, join, relative, sep } from "path"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Flag } from "./flag/flag"
|
||||
@@ -8,7 +8,6 @@ import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SystemContext } from "./system-context/index"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||
@@ -19,12 +18,18 @@ class File extends Schema.Class<File>("InstructionContext.File")({
|
||||
const Files = Schema.Array(File)
|
||||
const key = SystemContext.Key.make("core/instructions")
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionContext") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
|
||||
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
|
||||
SystemContext.make({
|
||||
@@ -71,28 +76,24 @@ const layer = Layer.effectDiscard(
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
})
|
||||
|
||||
yield* registry.register({
|
||||
key,
|
||||
load: observe().pipe(
|
||||
Effect.map((files) =>
|
||||
files === SystemContext.unavailable
|
||||
? source(files)
|
||||
: files.length === 0
|
||||
? SystemContext.empty
|
||||
: source(files),
|
||||
return Service.of({
|
||||
load: () =>
|
||||
observe().pipe(
|
||||
Effect.map((files) =>
|
||||
files === SystemContext.unavailable
|
||||
? source(files)
|
||||
: files.length === 0
|
||||
? SystemContext.empty
|
||||
: source(files),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "instruction-context",
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
|
||||
})
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.node] })
|
||||
|
||||
function render(files: ReadonlyArray<File>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
|
||||
@@ -36,8 +36,9 @@ import { SessionTodo } from "./session/todo"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { InstructionContext } from "./instruction-context"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { SessionInstructions } from "./session/instructions"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
@@ -67,8 +68,8 @@ export const locationServices = LayerNode.group([
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
MCP.node,
|
||||
@@ -85,6 +86,7 @@ export const locationServices = LayerNode.group([
|
||||
ReadToolFileSystem.node,
|
||||
BuiltInTools.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
|
||||
@@ -14,16 +14,40 @@ const Summary = Schema.Struct({
|
||||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||
servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
])
|
||||
|
||||
const render = (servers: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"<mcp_instructions>",
|
||||
...servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
]),
|
||||
"</mcp_instructions>",
|
||||
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
|
||||
|
||||
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
|
||||
const diff = SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(server) => server.server,
|
||||
(before, after) => before.instructions !== after.instructions,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
return [
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
render(current),
|
||||
].join("\n")
|
||||
return [
|
||||
...(diff.added.length === 0
|
||||
? []
|
||||
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]),
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
`Instructions for the following MCP servers are no longer available: ${diff.removed.map((server) => server.server).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||
@@ -50,7 +74,8 @@ export const layer = Layer.effect(
|
||||
return (
|
||||
owned.length === 0 ||
|
||||
owned.some(
|
||||
(tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -61,11 +86,7 @@ export const layer = Layer.effect(
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(visible),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
update,
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -26,8 +26,13 @@ export type Api = Model.Api
|
||||
export const Info = Model.Info
|
||||
export type Info = Model.Info
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
|
||||
export type MutableRequest = ProviderV2.MutableRequest & { variant?: string }
|
||||
export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID }
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request" | "variants"> & {
|
||||
api: ProviderV2.MutableApi<Api>
|
||||
request: MutableRequest
|
||||
variants: MutableVariant[]
|
||||
}
|
||||
|
||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ToolHooks } from "./tool/hooks"
|
||||
|
||||
export const ID = Plugin.ID
|
||||
export type ID = typeof ID.Type
|
||||
@@ -165,6 +166,7 @@ export const node = makeLocationNode({
|
||||
Reference.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
ToolHooks.node,
|
||||
PluginRuntime.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Reference } from "../reference"
|
||||
import { AbsolutePath, type DeepMutable } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { ToolHooks } from "../tool/hooks"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
@@ -31,6 +32,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
@@ -247,6 +249,47 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
},
|
||||
tool: {
|
||||
register: (input) => tools.register(input),
|
||||
execute: {
|
||||
before: (callback) =>
|
||||
toolHooks.hook.before((event) => {
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
assistantMessageID: event.assistantMessageID,
|
||||
toolCallID: event.toolCallID,
|
||||
input: event.input,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.input = output.input))),
|
||||
)
|
||||
}),
|
||||
after: (callback) =>
|
||||
toolHooks.hook.after((event) => {
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
assistantMessageID: event.assistantMessageID,
|
||||
toolCallID: event.toolCallID,
|
||||
input: event.input,
|
||||
result: event.result,
|
||||
output: event.output,
|
||||
outputPaths: event.outputPaths,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
event.result = output.result
|
||||
event.output = output.output
|
||||
event.outputPaths = output.outputPaths
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: (input) =>
|
||||
|
||||
@@ -70,25 +70,73 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
|
||||
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
|
||||
}
|
||||
|
||||
function reasoningVariants(model: ModelsDev.Model, packageName: string | undefined): ModelV2Info["variants"] {
|
||||
const result = new Map<ModelV2.VariantID, ModelV2Info["variants"][number]>()
|
||||
if (packageName === "@ai-sdk/openai" || packageName === "@ai-sdk/openai-compatible") {
|
||||
const option = model.reasoning_options?.find((option) => option.type === "effort")
|
||||
for (const value of option?.values ?? []) {
|
||||
const id = value === null ? "none" : value
|
||||
if (typeof id !== "string") continue
|
||||
const variantID = ModelV2.VariantID.make(id)
|
||||
result.set(variantID, {
|
||||
id: variantID,
|
||||
headers: {},
|
||||
body:
|
||||
packageName === "@ai-sdk/openai"
|
||||
? { include: ["reasoning.encrypted_content"], reasoning: { effort: id, summary: "auto" } }
|
||||
: { reasoning_effort: id },
|
||||
})
|
||||
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
|
||||
|
||||
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] {
|
||||
const npm = model.provider?.npm ?? provider.npm
|
||||
const options = model.reasoning_options ?? []
|
||||
const effort = options.find((option) => option.type === "effort")
|
||||
if (effort?.type === "effort") {
|
||||
return effort.values.flatMap((value) => {
|
||||
const raw: unknown = value
|
||||
const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined
|
||||
if (id === undefined) return []
|
||||
const settings = settingsForEffort(npm, id)
|
||||
return settings ? [{ id, settings, headers: {}, body: {} }] : []
|
||||
})
|
||||
}
|
||||
|
||||
const budget = options.find((option) => option.type === "budget_tokens")
|
||||
if (budget?.type === "budget_tokens") return budgetVariants(npm, budget)
|
||||
|
||||
// Toggle-only reasoning is intentionally left for a follow-up because V1 has
|
||||
// provider/model-specific behavior like MiniMax M3 adaptive thinking and
|
||||
// Qwen/GLM enable_thinking request shapes in packages/opencode.
|
||||
return []
|
||||
}
|
||||
|
||||
function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined {
|
||||
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } }
|
||||
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
|
||||
return { thinking: { type: "adaptive", display: "summarized" }, effort }
|
||||
}
|
||||
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
|
||||
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
|
||||
}
|
||||
if (npm === "@ai-sdk/azure") return { reasoningEffort: effort }
|
||||
if (npm === "@ai-sdk/openai") {
|
||||
return {
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: OPENAI_INCLUDE_ENCRYPTED_REASONING,
|
||||
}
|
||||
}
|
||||
return [...result.values()]
|
||||
if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort }
|
||||
}
|
||||
|
||||
function budgetVariants(
|
||||
npm: string | undefined,
|
||||
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
||||
): ModelV2Info["variants"] {
|
||||
const max = option.max
|
||||
const high = option.max === undefined ? Math.max(option.min ?? 0, 16_000) : Math.min(Math.max(option.min ?? 0, 16_000), option.max)
|
||||
return [
|
||||
{ id: "high", budget: high },
|
||||
...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]),
|
||||
].flatMap((item) => {
|
||||
const settings = settingsForBudget(npm, item.budget)
|
||||
return settings ? [{ id: item.id, settings, headers: {}, body: {} }] : []
|
||||
})
|
||||
}
|
||||
|
||||
function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined {
|
||||
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } }
|
||||
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
|
||||
return { thinking: { type: "enabled", budgetTokens: budget } }
|
||||
}
|
||||
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
|
||||
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
|
||||
}
|
||||
}
|
||||
|
||||
function modeName(model: ModelsDev.Model, mode: string) {
|
||||
@@ -193,7 +241,7 @@ export const ModelsDevPlugin = define({
|
||||
|
||||
for (const model of Object.values(item.models)) {
|
||||
const baseCost = cost(model.cost)
|
||||
const variants = reasoningVariants(model, model.provider?.npm ?? item.npm)
|
||||
const variants = reasoningVariants(item, model)
|
||||
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
|
||||
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
|
||||
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export * as OpenAICodex from "./openai-codex"
|
||||
|
||||
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
|
||||
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
|
||||
// in OpenAIPlugin, sharing this module. Once the native provider packages land
|
||||
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
|
||||
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
|
||||
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
|
||||
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
|
||||
// provider entry could replace the hardcoded rules with catalog data.
|
||||
|
||||
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
|
||||
export const baseURL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
|
||||
|
||||
/** Structural credential shape so both core and plugin-facing credential types fit. */
|
||||
type CredentialLike = {
|
||||
readonly type: string
|
||||
readonly methodID?: string
|
||||
readonly metadata?: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
export const isChatGPT = (credential: CredentialLike | undefined) =>
|
||||
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
|
||||
|
||||
export const accountID = (credential: CredentialLike | undefined) => {
|
||||
if (!isChatGPT(credential)) return undefined
|
||||
const value = credential?.metadata?.accountID
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||
const disallowed = new Set(["gpt-5.5-pro"])
|
||||
|
||||
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
|
||||
export const eligible = (apiID: string) => {
|
||||
if (allowed.has(apiID)) return true
|
||||
if (disallowed.has(apiID)) return false
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
return match ? Number.parseFloat(match[1]) > 5.4 : false
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Deferred, Effect, Semaphore, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { EventV2 } from "../../event"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import type { PluginInternal } from "../internal"
|
||||
import { OpenAICodex } from "./openai-codex"
|
||||
|
||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const issuer = "https://auth.openai.com"
|
||||
@@ -154,6 +156,18 @@ const headless = {
|
||||
export const OpenAIPlugin = define({
|
||||
id: "openai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let chatgpt = false
|
||||
|
||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("openai")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
chatgpt = OpenAICodex.isChatGPT(credential)
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(browser)
|
||||
draft.method.update(headless)
|
||||
@@ -170,8 +184,30 @@ export const OpenAIPlugin = define({
|
||||
model.enabled = false
|
||||
})
|
||||
}
|
||||
if (!chatgpt) return
|
||||
const item = evt.provider.get(ProviderV2.ID.openai)
|
||||
if (!item) return
|
||||
for (const model of item.models.values()) {
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
evt.model.update(item.provider.id, model.id, (draft) => {
|
||||
if (!OpenAICodex.eligible(draft.api.id)) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
draft.cost = []
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
|
||||
@@ -146,7 +146,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||
const variantID = ModelV2.VariantID.make(id)
|
||||
let existing = model.variants.find((item) => item.id === variantID)
|
||||
if (!existing) {
|
||||
existing = { id: variantID, headers: {}, body: {} }
|
||||
existing = { id: variantID, settings: {}, headers: {}, body: {} }
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.headers, options.headers)
|
||||
|
||||
@@ -110,8 +110,13 @@ export const providerLayer = providerLayerWithCell(defaultCell)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
export const providerNode = makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayer,
|
||||
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||
})
|
||||
// Raw layer replacements are compiled without dependencies, so cell-scoped
|
||||
// provider replacements must go through this node to keep their deps wired.
|
||||
export const providerNodeWithCell = (cell: Cell) =>
|
||||
makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayerWithCell(cell),
|
||||
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||
})
|
||||
|
||||
export const providerNode = providerNodeWithCell(defaultCell)
|
||||
|
||||
@@ -33,7 +33,8 @@ export function generate(model: ModelV2Info): ModelV2Info["variants"] {
|
||||
if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return []
|
||||
return ["high", "max"].map((id) => ({
|
||||
id,
|
||||
settings: { reasoningEffort: id },
|
||||
headers: {},
|
||||
body: { reasoning_effort: id },
|
||||
body: {},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -19,7 +19,15 @@ export type MutableApi<T extends Api = Api> = T extends Api
|
||||
export const Request = Provider.Request
|
||||
export type Request = Provider.Request
|
||||
|
||||
export const Settings = Provider.Settings
|
||||
export type Settings = Provider.Settings
|
||||
|
||||
export const Info = Provider.Info
|
||||
export type Info = Provider.Info
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
|
||||
export type MutableRequest = Types.DeepMutable<Request>
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request"> & {
|
||||
api: MutableApi
|
||||
request: MutableRequest
|
||||
}
|
||||
|
||||
@@ -11,20 +11,48 @@ const Summary = Schema.Struct({
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const entries = (references: ReadonlyArray<typeof Summary.Type>) =>
|
||||
references.flatMap((reference) => [
|
||||
" <reference>",
|
||||
` <name>${reference.name}</name>`,
|
||||
` <path>${reference.path}</path>`,
|
||||
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
|
||||
" </reference>",
|
||||
])
|
||||
|
||||
const render = (references: ReadonlyArray<typeof Summary.Type>) =>
|
||||
[
|
||||
"Project references provide additional directories that can be accessed when relevant.",
|
||||
"<available_references>",
|
||||
...references.flatMap((reference) => [
|
||||
" <reference>",
|
||||
` <name>${reference.name}</name>`,
|
||||
` <path>${reference.path}</path>`,
|
||||
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
|
||||
" </reference>",
|
||||
]),
|
||||
...entries(references),
|
||||
"</available_references>",
|
||||
].join("\n")
|
||||
|
||||
const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyArray<typeof Summary.Type>) => {
|
||||
const diff = SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(reference) => reference.name,
|
||||
(before, after) => before.path !== after.path || before.description !== after.description,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
return [
|
||||
"The available project references have changed. This list supersedes the previous reference list.",
|
||||
render(current),
|
||||
].join("\n")
|
||||
return [
|
||||
...(diff.added.length === 0
|
||||
? []
|
||||
: ["New project references are available in addition to those previously listed:", ...entries(diff.added)]),
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
`The following project references are no longer available and must not be used: ${diff.removed.map((reference) => reference.name).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
@@ -52,11 +80,7 @@ const layer = Layer.effect(
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(available),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available project references have changed. This list supersedes the previous reference list.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
update,
|
||||
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -108,7 +108,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
|
||||
},
|
||||
) {}
|
||||
|
||||
export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
|
||||
export { MessageDecodeError } from "./session/error"
|
||||
|
||||
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -199,6 +199,7 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly revert: {
|
||||
readonly stage: (input: {
|
||||
@@ -465,7 +466,9 @@ const layer = Layer.effect(
|
||||
text: skill.content,
|
||||
})
|
||||
if (input.resume !== false)
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
@@ -547,8 +550,11 @@ const layer = Layer.effect(
|
||||
timestamp: yield* DateTime.now,
|
||||
text: input.text,
|
||||
description: input.description,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
export * as SessionContextCheckpoint from "./context-checkpoint"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Option, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextCheckpointTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied)
|
||||
|
||||
/**
|
||||
* Loads or creates the session's durable context checkpoint, narrating any
|
||||
* drift since the model was last told as a chronological update. Completed
|
||||
* compaction rebaselines; nothing else rewrites the baseline. Runs before
|
||||
* input promotion so a blocked first turn leaves pending inputs untouched.
|
||||
*/
|
||||
export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const baseline = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, baseline)
|
||||
return { baseline: baseline.text, baselineSeq }
|
||||
}
|
||||
|
||||
// The applied record is comparison state only; an undecodable one heals by
|
||||
// treating every source as new, re-announcing baselines as updates.
|
||||
const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({}))
|
||||
if (compaction !== undefined && compaction.seq > stored.baseline_seq) {
|
||||
const baseline = yield* SystemContext.rebaseline(value, applied)
|
||||
yield* rewrite(db, sessionID, compaction.seq, baseline)
|
||||
return { baseline: baseline.text, baselineSeq: compaction.seq }
|
||||
}
|
||||
const result = yield* SystemContext.reconcile(value, applied)
|
||||
if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("SessionContextCheckpoint.reset")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
yield* db
|
||||
.delete(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baseline: SystemContext.Baseline,
|
||||
) {
|
||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: baseline.text,
|
||||
snapshot: baseline.applied,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const rewrite = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
baseline: SystemContext.Baseline,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextCheckpointTable)
|
||||
.set({
|
||||
baseline: baseline.text,
|
||||
snapshot: baseline.applied,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextCheckpointTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context checkpoint not found")
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
applied: SystemContext.Applied,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextCheckpointTable)
|
||||
.set({ snapshot: applied })
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextCheckpointTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context checkpoint not found")
|
||||
})
|
||||
@@ -1,174 +0,0 @@
|
||||
export * as SessionContextEpoch from "./context-epoch"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { ContextSnapshotDecodeError } from "./error"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
interface Prepared {
|
||||
readonly baseline: string
|
||||
readonly baselineSeq: number
|
||||
}
|
||||
|
||||
export function initialize(
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
|
||||
}
|
||||
|
||||
export function prepare(
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
|
||||
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
|
||||
}
|
||||
|
||||
const prepareOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const generation = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
|
||||
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
|
||||
)
|
||||
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
|
||||
const result = replacementSeq
|
||||
? yield* SystemContext.replace(value, snapshot)
|
||||
: yield* SystemContext.reconcile(value, snapshot)
|
||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
}
|
||||
if (result._tag === "ReplacementReady") {
|
||||
const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID))
|
||||
yield* replace(db, sessionID, baselineSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
const initializeOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
if (yield* exists(db, sessionID)) return
|
||||
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return (
|
||||
(yield* db
|
||||
.select({ sessionID: SessionContextEpochTable.session_id })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
yield* db
|
||||
.delete(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const replace = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
snapshot: SystemContext.Snapshot,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ snapshot })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
@@ -10,15 +10,3 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
|
||||
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
|
||||
}
|
||||
}
|
||||
|
||||
export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass<ContextSnapshotDecodeError>()(
|
||||
"Session.ContextSnapshotDecodeError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
details: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Failed to decode context snapshot for session ${this.sessionID}: ${this.details}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
|
||||
import { SessionContextCheckpointTable, SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
@@ -33,6 +33,9 @@ const messageRows = Effect.fnUntraced(function* (
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
// Keep system updates visible in the gap between a completed compaction
|
||||
// and the next prepared turn's rebaseline, when their content is not yet
|
||||
// folded into a new baseline.
|
||||
compaction
|
||||
? or(
|
||||
gte(SessionMessageTable.seq, compaction.seq),
|
||||
@@ -67,9 +70,9 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
||||
const [epoch, compaction] = yield* Effect.all(
|
||||
[
|
||||
db
|
||||
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.select({ baselineSeq: SessionContextCheckpointTable.baseline_seq })
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
latestCompaction(db, sessionID),
|
||||
@@ -79,14 +82,6 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
||||
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
||||
})
|
||||
|
||||
export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) {
|
||||
return (yield* entriesForRunner(db, sessionID, baselineSeq)).map((entry) => entry.message)
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionInput from "./input"
|
||||
|
||||
import { and, asc, eq, isNull, lte } from "drizzle-orm"
|
||||
import { and, asc, eq, isNull } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
|
||||
import type { Database } from "../database/database"
|
||||
@@ -145,26 +145,11 @@ export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(functio
|
||||
return
|
||||
}
|
||||
|
||||
// Every Prompted event is published from an admitted inbox row, so a missing or
|
||||
// divergent row on replay is an invariant violation.
|
||||
const stored = yield* find(db, input.id)
|
||||
if (stored) {
|
||||
if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq)
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return
|
||||
}
|
||||
|
||||
yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
admitted_seq: input.promotedSeq,
|
||||
promoted_seq: input.promotedSeq,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored || !matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq)
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
||||
@@ -195,9 +180,8 @@ export const equivalent = (
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
},
|
||||
) => input.delivery === expected.delivery && matchesPrompt(input, expected)
|
||||
|
||||
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
|
||||
) =>
|
||||
input.delivery === expected.delivery &&
|
||||
input.sessionID === expected.sessionID &&
|
||||
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
||||
|
||||
@@ -246,7 +230,6 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
cutoff: number,
|
||||
) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
@@ -256,7 +239,6 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
||||
eq(SessionInputTable.session_id, sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
eq(SessionInputTable.delivery, "steer"),
|
||||
lte(SessionInputTable.admitted_seq, cutoff),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
export * as SessionInstructions from "./instructions"
|
||||
|
||||
import { relative } from "path"
|
||||
import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Location } from "../location"
|
||||
import { SessionEvent } from "./event"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionStore } from "./store"
|
||||
|
||||
const InjectedMetadata = Schema.Struct({
|
||||
instruction: Schema.Struct({ paths: Schema.Array(Schema.String) }),
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) => Effect.Effect<void, MessageDecodeError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionInstructions") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const location = yield* Location.Service
|
||||
// Resolved once for the Location layer; the synthetic text and dedup ledger keep
|
||||
// absolute paths, but the human-facing description shows paths relative to the project
|
||||
// root so opening a subdirectory still describes paths from the project root.
|
||||
const root = FSUtil.resolve(location.project.directory)
|
||||
// Same-turn parallel reads settle concurrently, so an in-memory claim guards each
|
||||
// Session/path pair before any filesystem work. The durable history check below covers
|
||||
// paths injected in earlier turns after this Location layer was reopened.
|
||||
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) {
|
||||
const claimed = yield* Ref.modify(injected, (map) => {
|
||||
const existing = map.get(input.sessionID) ?? new Set<string>()
|
||||
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
|
||||
if (newlyClaimed.length === 0) return [newlyClaimed, map]
|
||||
const next = new Map(map)
|
||||
next.set(input.sessionID, new Set([...existing, ...newlyClaimed]))
|
||||
return [newlyClaimed, next]
|
||||
})
|
||||
if (claimed.length === 0) return
|
||||
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
|
||||
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
|
||||
if (toInject.length === 0) return
|
||||
const files = yield* Effect.forEach(
|
||||
toInject,
|
||||
(path) =>
|
||||
fs.readFileStringSafe(path).pipe(
|
||||
Effect.map((content) => (content === undefined ? undefined : { path, content })),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
||||
if (readable.length === 0) return
|
||||
// Publish directly rather than through SessionV2.synthetic: a Location-scoped layer
|
||||
// cannot depend on SessionV2 (it routes through LocationServiceMap, forming a type
|
||||
// cycle with this node). The durable publish is what makes the synthetic visible on
|
||||
// the next projected history reload. The dedup ledger lives on the synthetic message
|
||||
// metadata so it survives across Location layer restarts.
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
|
||||
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
|
||||
metadata: { instruction: { paths: readable.map((file) => file.path) } },
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ load })
|
||||
}),
|
||||
)
|
||||
|
||||
function previouslyInjected(store: SessionStore.Interface, sessionID: SessionSchema.ID) {
|
||||
return Effect.gen(function* () {
|
||||
const history = yield* store.context(sessionID)
|
||||
return new Set(
|
||||
history
|
||||
.filter((message): message is SessionMessage.Synthetic => message.type === "synthetic")
|
||||
.flatMap(
|
||||
(message) =>
|
||||
Option.getOrUndefined(Schema.decodeUnknownOption(InjectedMetadata)(message.metadata))?.instruction.paths ??
|
||||
[],
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Paths are normally discovered under the project root, so the description shows them
|
||||
// relative to it. A directly-loaded path outside the root falls back to its absolute form
|
||||
// rather than emitting `../..` chains.
|
||||
function describePath(root: string, path: string) {
|
||||
return FSUtil.contains(root, path) ? relative(root, path) : path
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "session-instructions",
|
||||
layer,
|
||||
deps: [EventV2.node, FSUtil.node, Location.node, SessionStore.node],
|
||||
})
|
||||
@@ -139,6 +139,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
},
|
||||
"session.next.prompt.admitted": () => Effect.void,
|
||||
"session.next.execution.settled": () => Effect.void,
|
||||
"session.next.context.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
SessionMessage.System.make({
|
||||
@@ -154,6 +155,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
id: event.data.messageID,
|
||||
type: "synthetic",
|
||||
time: { created: event.data.timestamp },
|
||||
|
||||
@@ -12,8 +12,15 @@ import { SessionMessage } from "./message"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionInput } from "./input"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { SessionContextEpoch } from "./context-epoch"
|
||||
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import { SessionContextCheckpoint } from "./context-checkpoint"
|
||||
import {
|
||||
MessageTable,
|
||||
PartTable,
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { Slug } from "../util/slug"
|
||||
|
||||
@@ -156,12 +163,16 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.messageID)),
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
eq(SessionMessageTable.id, event.data.messageID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (event.data.messageID && !boundary) return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`)
|
||||
if (event.data.messageID && !boundary)
|
||||
return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`)
|
||||
const copied = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
@@ -206,6 +217,23 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||
|
||||
// The fork inherits the parent's transcript, so it inherits the context
|
||||
// checkpoint that transcript was built against: copied message seqs keep
|
||||
// folding at the same baseline horizon.
|
||||
const checkpoint = yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, event.data.parentID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (checkpoint) {
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.values({ ...checkpoint, session_id: event.data.sessionID })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const usage = emptyUsage()
|
||||
let cursor = -1
|
||||
while (true) {
|
||||
@@ -452,7 +480,7 @@ const layer = Layer.effectDiscard(
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||
@@ -666,7 +694,7 @@ const layer = Layer.effectDiscard(
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -6,19 +6,26 @@ import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
export interface Coordinator<Key, E> {
|
||||
/** Snapshots keys with an execution owned by this coordinator. */
|
||||
readonly active: Effect.Effect<ReadonlySet<Key>>
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
/** Starts an execution while idle or joins the active execution. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Registers one coalesced follow-up after newly recorded work. */
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops active execution and waits for its cleanup. */
|
||||
/** Stops the active execution and waits for its cleanup. */
|
||||
readonly interrupt: (key: Key) => 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>
|
||||
}
|
||||
|
||||
type Entry<E> = {
|
||||
/**
|
||||
* One execution is a busy period for one key: one fiber that drains from the first wake
|
||||
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
||||
* execution rings it, and the execution loop drains again instead of ending. The doorbell
|
||||
* closes the gap between a drain's last eligibility check and the idle transition, since
|
||||
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
||||
*/
|
||||
type Execution<E> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void, never>
|
||||
owner?: Fiber.Fiber<void>
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
}
|
||||
@@ -27,90 +34,83 @@ export const make = <Key, E>(options: {
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const active = new Map<Key, Entry<E>>()
|
||||
const executions = new Map<Key, Execution<E>>()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
|
||||
const makeEntry = (): Entry<E> => ({
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
})
|
||||
const loop = (key: Key, execution: Execution<E>, force: boolean): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.suspend(() => {
|
||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
||||
execution.pendingWake = false
|
||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
|
||||
const ready = Deferred.makeUnsafe<void>()
|
||||
const owner = fork(
|
||||
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => options.drain(key, force))),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
|
||||
const start = (key: Key, force: boolean) => {
|
||||
const execution: Execution<E> = { done: Deferred.makeUnsafe<void, E>(), pendingWake: false, stopping: false }
|
||||
executions.set(key, execution)
|
||||
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
|
||||
// failing self-waking executions from growing the stack across successor starts.
|
||||
// Drains start one tick after wake; callers observe progress through events or run.
|
||||
execution.owner = fork(
|
||||
Effect.yieldNow.pipe(
|
||||
Effect.andThen(loop(key, execution, force)),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
)
|
||||
entry.owner = owner
|
||||
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
|
||||
return execution
|
||||
}
|
||||
|
||||
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
|
||||
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
|
||||
entry.pendingWake = false
|
||||
start(key, entry, false, true)
|
||||
return
|
||||
}
|
||||
|
||||
const successor = entry.pendingWake ? makeEntry() : undefined
|
||||
if (successor === undefined) active.delete(key)
|
||||
else {
|
||||
active.set(key, successor)
|
||||
start(key, successor, false, true)
|
||||
}
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
Effect.uninterruptibleMask((restore) => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
|
||||
return restore(Deferred.await(entry.done))
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
if (execution.stopping) return restore(Deferred.await(execution.done).pipe(Effect.andThen(run(key))))
|
||||
return restore(Deferred.await(execution.done))
|
||||
}
|
||||
|
||||
const next = makeEntry()
|
||||
active.set(key, next)
|
||||
start(key, next, true)
|
||||
return restore(Deferred.await(next.done))
|
||||
return restore(Deferred.await(start(key, true).done))
|
||||
})
|
||||
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
entry.pendingWake = true
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
execution.pendingWake = true
|
||||
return
|
||||
}
|
||||
|
||||
const next = makeEntry()
|
||||
active.set(key, next)
|
||||
start(key, next, false)
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const interrupt = (key: Key): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry?.owner === undefined) return Effect.void
|
||||
entry.stopping = true
|
||||
entry.pendingWake = false
|
||||
return Fiber.interrupt(entry.owner)
|
||||
const execution = executions.get(key)
|
||||
if (execution?.owner === undefined) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = false
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
|
||||
// Each successful drain reuses its entry.done across coalesced wakes, so one await
|
||||
// already spans steered and queued continuation. Re-check after it settles to cover a
|
||||
// fresh wake (or a failure/stopping successor) that installs a new entry.
|
||||
// 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> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry === undefined) return Effect.void
|
||||
return Deferred.await(entry.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined) return Effect.void
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import type { MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
@@ -12,7 +12,6 @@ export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
@@ -18,13 +18,14 @@ import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { QuestionV2 } from "../../question"
|
||||
import { SystemContext } from "../../system-context/index"
|
||||
import { SystemContextRegistry } from "../../system-context/registry"
|
||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
||||
import { InstructionContext } from "../../instruction-context"
|
||||
import { SkillGuidance } from "../../skill/guidance"
|
||||
import { ReferenceGuidance } from "../../reference/guidance"
|
||||
import { McpGuidance } from "../../mcp/guidance"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { ToolOutputStore } from "../../tool-output-store"
|
||||
import { SessionContextEpoch } from "../context-epoch"
|
||||
import { SessionContextCheckpoint } from "../context-checkpoint"
|
||||
import { SessionCompaction } from "../compaction"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionHistory } from "../history"
|
||||
@@ -32,7 +33,7 @@ import { SessionInput } from "../input"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionTitle } from "../title"
|
||||
import { type RunError, Service } from "./index"
|
||||
import { Service } from "./index"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { toLLMMessages } from "./to-llm-message"
|
||||
@@ -102,7 +103,8 @@ const layer = Layer.effect(
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const location = yield* Location.Service
|
||||
const systemContext = yield* SystemContextRegistry.Service
|
||||
const builtins = yield* SystemContextBuiltIns.Service
|
||||
const instructions = yield* InstructionContext.Service
|
||||
const skillGuidance = yield* SkillGuidance.Service
|
||||
const referenceGuidance = yield* ReferenceGuidance.Service
|
||||
const mcpGuidance = yield* McpGuidance.Service
|
||||
@@ -153,26 +155,17 @@ const layer = Layer.effect(
|
||||
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
|
||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||
|
||||
type TurnTransition =
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
| { readonly _tag: "ContinueAfterCompaction"; readonly step: number }
|
||||
// Overflow compaction completed; rebuild once through the path without overflow recovery.
|
||||
| { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number }
|
||||
|
||||
class TurnTransitionError extends Error {
|
||||
constructor(readonly transition: TurnTransition) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
|
||||
const continueAfterOverflowCompaction = (step: number) =>
|
||||
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
|
||||
|
||||
const loadSystemContext = (agent: AgentV2.Selection) =>
|
||||
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], {
|
||||
concurrency: "unbounded",
|
||||
}).pipe(Effect.map(SystemContext.combine))
|
||||
Effect.all(
|
||||
[
|
||||
builtins.load(),
|
||||
instructions.load(),
|
||||
skillGuidance.load(agent),
|
||||
referenceGuidance.load(),
|
||||
mcpGuidance.load(agent),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map(SystemContext.combine))
|
||||
|
||||
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -184,24 +177,23 @@ const layer = Layer.effect(
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first turn leaves pending inputs untouched.
|
||||
const checkpoint = yield* SessionContextCheckpoint.prepare(db, events, loadSystemContext(agent), session.id)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
const cutoff = yield* EventV2.latestSequence(db, session.id)
|
||||
let promoted = 0
|
||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
if (promotion === "queue") {
|
||||
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
|
||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
}
|
||||
if (promoted > 0) currentStep = 1
|
||||
}
|
||||
const system =
|
||||
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const toolMaterialization = isLastStep
|
||||
@@ -211,15 +203,19 @@ const layer = Layer.effect(
|
||||
const request = LLM.request({
|
||||
model,
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline]
|
||||
system: [
|
||||
agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model),
|
||||
checkpoint.baseline,
|
||||
]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
|
||||
return yield* Effect.die(continueAfterCompaction(currentStep))
|
||||
return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
@@ -284,44 +280,71 @@ const layer = Layer.effect(
|
||||
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
// Gather the evidence: how did the provider stream end?
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
const failure =
|
||||
stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
|
||||
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
|
||||
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the turn instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasAssistantStarted() &&
|
||||
isContextOverflowFailure(overflowFailure ?? failure) &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
|
||||
)
|
||||
return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
// An unrecovered held-back overflow becomes the turn's durable provider error. A
|
||||
// thrown LLM failure fails hosted tool calls and the assistant unless a provider
|
||||
// error was already recorded from the stream.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = failure instanceof LLMError ? failure : undefined
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* publication.withPermit(publisher.failAssistant(llmFailure.reason.message))
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
// Settle tool fibers: an interrupted stream abandons unstarted tool work first.
|
||||
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
||||
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
|
||||
const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause)
|
||||
|
||||
if (questionDismissed || streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
|
||||
return yield* Effect.interrupt
|
||||
// Match V1: dismissing a question halts the loop like an interruption.
|
||||
if (questionDismissed) return yield* Effect.interrupt
|
||||
}
|
||||
if (streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
|
||||
}
|
||||
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||
// implementation becomes a failed tool call the model can read, and the turn still
|
||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
||||
// could not be persisted) also fails the assistant and then fails the drain.
|
||||
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
|
||||
const infraError =
|
||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
||||
if (settledFailure !== undefined) {
|
||||
const failure = infraError ?? Cause.squash(settledFailure)
|
||||
const message = failure instanceof Error ? failure.message : String(failure)
|
||||
yield* publication.withPermit(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
if (infraError !== undefined)
|
||||
yield* publication.withPermit(publisher.failAssistant(`Tool execution failed: ${message}`))
|
||||
}
|
||||
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
|
||||
if (
|
||||
stepSettlement &&
|
||||
!streamInterrupted &&
|
||||
!toolsInterrupted &&
|
||||
infraError === undefined &&
|
||||
!providerFailed
|
||||
) {
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
@@ -342,52 +365,46 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (publisher.hasProviderError())
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !publisher.hasProviderError())
|
||||
// A provider error orphans recorded local calls; a clean stream can still leave
|
||||
// hosted calls without results.
|
||||
if (providerFailed) yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !providerFailed)
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
|
||||
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
|
||||
return yield* Effect.failCause(settled.cause)
|
||||
return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
|
||||
return {
|
||||
_tag: "Completed",
|
||||
needsContinuation: !providerFailed && needsContinuation,
|
||||
step: currentStep,
|
||||
} as const
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
type RunTurn = (
|
||||
|
||||
const runTurn = Effect.fnUntraced(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError>
|
||||
|
||||
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step).pipe(
|
||||
Effect.catchDefect(
|
||||
Effect.fnUntraced(function* (defect) {
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
||||
yield* Effect.yieldNow
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
) {
|
||||
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
||||
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
|
||||
// overflow, so the recovery hook is dropped after it fires.
|
||||
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
|
||||
let currentPromotion = promotion
|
||||
let currentStep = step
|
||||
while (true) {
|
||||
const attempt = yield* runTurnAttempt(sessionID, currentPromotion, currentStep, recoverOverflow)
|
||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||
yield* Effect.yieldNow
|
||||
currentPromotion = undefined
|
||||
currentStep = attempt.step
|
||||
}
|
||||
})
|
||||
|
||||
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
|
||||
Effect.catchDefect(
|
||||
Effect.fnUntraced(function* (defect) {
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
yield* Effect.yieldNow
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
|
||||
return yield* runTurn(sessionID, undefined, defect.transition.step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const run = Effect.fn("SessionRunner.run")(function* (input: {
|
||||
const drain = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
}) {
|
||||
@@ -418,6 +435,30 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const run = Effect.fn("SessionRunner.run")(
|
||||
(input: { readonly sessionID: SessionSchema.ID; readonly force: boolean }) =>
|
||||
drain(input).pipe(
|
||||
Effect.onExit((exit) =>
|
||||
Effect.gen(function* () {
|
||||
const failure =
|
||||
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
|
||||
yield* events.publish(SessionEvent.ExecutionSettled, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
|
||||
error:
|
||||
failure !== undefined
|
||||
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
|
||||
: undefined,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.asVoid,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
run,
|
||||
})
|
||||
@@ -435,7 +476,8 @@ export const node = makeLocationNode({
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
Location.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
McpGuidance.node,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { OpenAICodex } from "../../plugin/provider/openai-codex"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
||||
@@ -96,11 +97,22 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
provider: model.providerID,
|
||||
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
||||
headers: model.request.headers,
|
||||
providerOptions: providerOptions(model),
|
||||
http: { body: httpBody },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
}
|
||||
|
||||
const providerOptions = (
|
||||
model: ModelV2.Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (Object.keys(model.request.settings).length === 0) return undefined
|
||||
if (model.api.type !== "aisdk") return undefined
|
||||
if (model.api.package === "@ai-sdk/openai") return { openai: model.request.settings }
|
||||
if (model.api.package === "@ai-sdk/anthropic") return { anthropic: model.request.settings }
|
||||
if (model.api.package === "@ai-sdk/openai-compatible") return { openai: model.request.settings }
|
||||
}
|
||||
|
||||
export const withVariant = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
@@ -118,6 +130,7 @@ export const withVariant = (
|
||||
return Effect.succeed(
|
||||
variant
|
||||
? produce(model, (draft) => {
|
||||
Object.assign(draft.request.settings, variant.settings)
|
||||
Object.assign(draft.request.headers, variant.headers)
|
||||
Object.assign(draft.request.body, variant.body)
|
||||
})
|
||||
@@ -140,6 +153,21 @@ export const fromCatalogModel = (
|
||||
})
|
||||
const key = apiKey(resolved, credential)
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||
// ChatGPT-plan OAuth tokens are not API-key credentials: the public API rejects
|
||||
// them, so requests must target the codex backend with the account header.
|
||||
if (OpenAICodex.isChatGPT(credential)) {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
|
||||
@@ -130,7 +130,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
||||
}),
|
||||
]
|
||||
case "synthetic":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text })]
|
||||
case "skill":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
case "system":
|
||||
|
||||
@@ -165,12 +165,12 @@ export const SessionInputTable = sqliteTable(
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
||||
export const SessionContextCheckpointTable = sqliteTable("session_context_epoch", {
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
baseline: text().notNull(),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Applied>(),
|
||||
baseline_seq: integer().notNull(),
|
||||
})
|
||||
|
||||
@@ -14,10 +14,6 @@ import { fromRow } from "./info"
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly runnerContext: (
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
@@ -39,9 +35,6 @@ const layer = Layer.effect(
|
||||
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
||||
return yield* SessionHistory.load(db, sessionID)
|
||||
}),
|
||||
runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) {
|
||||
return yield* SessionHistory.loadForRunner(db, sessionID, baselineSeq)
|
||||
}),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
|
||||
@@ -13,23 +13,47 @@ const Summary = Schema.Struct({
|
||||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
||||
const entries = (skills: ReadonlyArray<Summary>) =>
|
||||
skills.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
" </skill>",
|
||||
])
|
||||
|
||||
const render = (skills: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
...(skills.length === 0
|
||||
? ["No skills are currently available."]
|
||||
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
|
||||
].join("\n")
|
||||
|
||||
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
|
||||
const diff = SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(skill) => skill.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
return [
|
||||
"The available skills have changed. This list supersedes the previous available skills list.",
|
||||
render(current),
|
||||
].join("\n")
|
||||
return [
|
||||
...(diff.added.length === 0
|
||||
? []
|
||||
: ["New skills are available in addition to those previously listed:", ...entries(diff.added)]),
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
"<available_skills>",
|
||||
...skills.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
`The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||
@@ -61,11 +85,7 @@ const layer = Layer.effect(
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(available),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available skills have changed. This list supersedes the previous available skills list.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
update,
|
||||
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
export * as SystemContextBuiltIns from "./builtins"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Location } from "../location"
|
||||
import { SystemContext } from "./index"
|
||||
import { InstructionContext } from "../instruction-context"
|
||||
import { SystemContextRegistry } from "./registry"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
|
||||
const builtIns = Layer.effectDiscard(
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextBuiltIns") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const environment = [
|
||||
"<env>",
|
||||
` Working directory: ${location.directory}`,
|
||||
@@ -39,12 +41,8 @@ const builtIns = Layer.effectDiscard(
|
||||
}),
|
||||
])
|
||||
|
||||
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||
return Service.of({ load: () => Effect.succeed(context) })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "system-context-builtins",
|
||||
layer: builtIns,
|
||||
deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node],
|
||||
})
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||
|
||||
@@ -7,13 +7,18 @@ import { Effect, Option, Schema } from "effect"
|
||||
*
|
||||
* `Source<A>` describes how to observe, compare, and render one value. `make`
|
||||
* closes over `A`, producing an opaque `SystemContext` that composes uniformly
|
||||
* with contexts built from other value types. Interpreters observe the composed
|
||||
* context once, then produce a durable structured
|
||||
* `Snapshot` alongside the exact model-visible baseline or update text.
|
||||
* with contexts built from other value types.
|
||||
*
|
||||
* The durable `Applied` record tracks what the model was last told, per source:
|
||||
* it is the model's current belief. Interpreters uphold one invariant —
|
||||
* `reconcile` never rewrites the baseline; it only narrates drift as update
|
||||
* text. Only `rebaseline` (compaction) and `initialize` (first turn) produce
|
||||
* baseline text.
|
||||
*
|
||||
* Returning `unavailable` means observation failed temporarily. It differs from
|
||||
* removing a source from the context: refresh preserves the admitted snapshot,
|
||||
* and replacement waits rather than silently constructing an incomplete baseline.
|
||||
* removing a source from the context: the model's prior belief stands.
|
||||
* `reconcile` retains the applied value silently, and `rebaseline` restates the
|
||||
* belief by rendering the last-applied value instead of a live observation.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -45,39 +50,30 @@ export interface SystemContext {
|
||||
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
|
||||
}
|
||||
|
||||
/** Durable comparison state for one admitted source. */
|
||||
export const SourceSnapshot = Schema.Struct({
|
||||
/** The value last applied to the model for one admitted source. */
|
||||
export const AppliedSource = Schema.Struct({
|
||||
value: Schema.Json,
|
||||
removed: Schema.optional(Schema.NonEmptyString),
|
||||
})
|
||||
export type SourceSnapshot = typeof SourceSnapshot.Type
|
||||
export type AppliedSource = typeof AppliedSource.Type
|
||||
|
||||
/** Durable structured comparison state for one active context generation. */
|
||||
export const Snapshot = Schema.Record(Key, SourceSnapshot)
|
||||
export type Snapshot = Readonly<Record<string, SourceSnapshot>>
|
||||
/** Durable record of what the model currently believes, per source. */
|
||||
export const Applied = Schema.Record(Key, AppliedSource)
|
||||
export type Applied = Readonly<Record<string, AppliedSource>>
|
||||
|
||||
export interface Generation {
|
||||
readonly baseline: string
|
||||
readonly snapshot: Snapshot
|
||||
/** A rendered baseline together with the applied values it was rendered from. */
|
||||
export interface Baseline {
|
||||
readonly text: string
|
||||
readonly applied: Applied
|
||||
}
|
||||
|
||||
export interface Updated {
|
||||
readonly _tag: "Updated"
|
||||
readonly text: string
|
||||
readonly snapshot: Snapshot
|
||||
readonly applied: Applied
|
||||
}
|
||||
|
||||
export interface ReplacementReady {
|
||||
readonly _tag: "ReplacementReady"
|
||||
readonly generation: Generation
|
||||
}
|
||||
|
||||
export interface ReplacementBlocked {
|
||||
readonly _tag: "ReplacementBlocked"
|
||||
}
|
||||
|
||||
export type ReplacementResult = ReplacementReady | ReplacementBlocked
|
||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult
|
||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated
|
||||
|
||||
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
|
||||
"SystemContext.InitializationBlocked",
|
||||
@@ -98,36 +94,24 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
|
||||
|
||||
interface PackedSource {
|
||||
readonly key: Key
|
||||
readonly load: Effect.Effect<Loaded | Unavailable>
|
||||
readonly load: Effect.Effect<Observed | Unavailable>
|
||||
/** Restates the model's belief from a last-applied value when the source cannot be observed. */
|
||||
readonly recall: (stored: AppliedSource) => string | undefined
|
||||
}
|
||||
|
||||
interface Loaded {
|
||||
readonly baseline: () => Rendered
|
||||
readonly compare: (previous: Schema.Json) => Compared
|
||||
interface Observed {
|
||||
readonly applied: AppliedSource
|
||||
readonly baseline: () => string
|
||||
/** `undefined` means unchanged. An undecodable previous value re-renders the baseline (treat-as-new). */
|
||||
readonly update: (previous: AppliedSource) => string | undefined
|
||||
}
|
||||
|
||||
interface Rendered {
|
||||
readonly text: string
|
||||
readonly snapshot: SourceSnapshot
|
||||
}
|
||||
|
||||
type Compared =
|
||||
| { readonly _tag: "Incompatible" }
|
||||
| { readonly _tag: "Unchanged" }
|
||||
| { readonly _tag: "Updated"; readonly render: () => Rendered }
|
||||
|
||||
interface AvailableEntry extends Loaded {
|
||||
readonly _tag: "Available"
|
||||
interface Entry {
|
||||
readonly key: Key
|
||||
readonly recall: PackedSource["recall"]
|
||||
readonly observed: Observed | Unavailable
|
||||
}
|
||||
|
||||
interface UnavailableEntry {
|
||||
readonly _tag: "Unavailable"
|
||||
readonly key: Key
|
||||
}
|
||||
|
||||
type Entry = AvailableEntry | UnavailableEntry
|
||||
|
||||
/** The identity context. */
|
||||
export const empty = context([])
|
||||
|
||||
@@ -136,42 +120,65 @@ export function make<A>(source: Source<A>): SystemContext {
|
||||
const decode = Schema.decodeUnknownOption(source.codec)
|
||||
const encode = Schema.encodeSync(source.codec)
|
||||
const equivalent = Schema.toEquivalence(source.codec)
|
||||
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
|
||||
return context([
|
||||
{
|
||||
key: source.key,
|
||||
recall: (stored) =>
|
||||
Option.match(decode(stored.value), {
|
||||
onNone: () => undefined,
|
||||
onSome: baseline,
|
||||
}),
|
||||
load: source.load.pipe(
|
||||
Effect.map((value) => {
|
||||
if (isUnavailable(value)) return value
|
||||
const snapshot = (): SourceSnapshot => ({
|
||||
value: encode(value),
|
||||
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
|
||||
})
|
||||
return {
|
||||
baseline: (): Rendered => ({
|
||||
text: requireText(source.key, "baseline", source.baseline(value)),
|
||||
snapshot: snapshot(),
|
||||
}),
|
||||
compare: (previous): Compared =>
|
||||
Option.match(decode(previous), {
|
||||
onNone: (): Compared => ({ _tag: "Incompatible" }),
|
||||
onSome: (decoded): Compared =>
|
||||
applied: {
|
||||
value: encode(value),
|
||||
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
|
||||
},
|
||||
baseline: () => baseline(value),
|
||||
update: (previous) =>
|
||||
Option.match(decode(previous.value), {
|
||||
onNone: () => baseline(value),
|
||||
onSome: (decoded) =>
|
||||
equivalent(decoded, value)
|
||||
? { _tag: "Unchanged" }
|
||||
: {
|
||||
_tag: "Updated",
|
||||
render: () => ({
|
||||
text: requireText(source.key, "update", source.update(decoded, value)),
|
||||
snapshot: snapshot(),
|
||||
}),
|
||||
},
|
||||
? undefined
|
||||
: requireText(source.key, "update", source.update(decoded, value)),
|
||||
}),
|
||||
}
|
||||
} satisfies Observed
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed three-way diff for list-shaped sources rendering delta updates.
|
||||
* `changed` compares two values sharing a key; entries equal under it are dropped.
|
||||
*/
|
||||
export function diffByKey<A>(
|
||||
previous: ReadonlyArray<A>,
|
||||
current: ReadonlyArray<A>,
|
||||
key: (value: A) => string,
|
||||
changed: (previous: A, current: A) => boolean,
|
||||
): {
|
||||
readonly added: ReadonlyArray<A>
|
||||
readonly removed: ReadonlyArray<A>
|
||||
readonly changed: ReadonlyArray<{ readonly previous: A; readonly current: A }>
|
||||
} {
|
||||
const currentKeys = new Set(current.map(key))
|
||||
const previousByKey = new Map(previous.map((value) => [key(value), value] as const))
|
||||
return {
|
||||
added: current.filter((value) => !previousByKey.has(key(value))),
|
||||
removed: previous.filter((value) => !currentKeys.has(key(value))),
|
||||
changed: current.flatMap((value) => {
|
||||
const before = previousByKey.get(key(value))
|
||||
return before === undefined || !changed(before, value) ? [] : [{ previous: before, current: value }]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Combines contexts in order and rejects duplicate source keys immediately. */
|
||||
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
|
||||
const sources = values.flatMap((value) => value[ContextTypeId])
|
||||
@@ -183,111 +190,91 @@ const observe = (value: SystemContext) =>
|
||||
Effect.forEach(
|
||||
value[ContextTypeId],
|
||||
(source) =>
|
||||
source.load.pipe(
|
||||
Effect.map(
|
||||
(result): Entry =>
|
||||
result === unavailable
|
||||
? { _tag: "Unavailable", key: source.key }
|
||||
: { _tag: "Available", key: source.key, ...result },
|
||||
),
|
||||
),
|
||||
source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
/** Creates the immutable baseline and durable snapshot for a new generation. */
|
||||
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
|
||||
/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */
|
||||
export function initialize(value: SystemContext): Effect.Effect<Baseline, InitializationBlocked> {
|
||||
return observe(value).pipe(
|
||||
Effect.flatMap((entries) => {
|
||||
const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
|
||||
if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })
|
||||
return Effect.succeed(initializeObservation(entries))
|
||||
const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : []))
|
||||
if (blocked.length > 0) return new InitializationBlocked({ keys: blocked })
|
||||
const parts: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
if (entry.observed === unavailable) continue
|
||||
parts.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
}
|
||||
return Effect.succeed({ text: render(parts), applied })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
|
||||
const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available")
|
||||
const rendered = available.map((entry) => [entry.key, entry.baseline()] as const)
|
||||
return {
|
||||
baseline: render(rendered.map(([, result]) => result.text)),
|
||||
snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])),
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconciles current source values with one active generation. */
|
||||
export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect<ReconcileResult> {
|
||||
/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */
|
||||
export function reconcile(value: SystemContext, previous: Applied): Effect.Effect<ReconcileResult> {
|
||||
return observe(value).pipe(
|
||||
Effect.map((entries): ReconcileResult => {
|
||||
const result = reconcileObservation(entries, previous)
|
||||
if (result._tag === "Unchanged" || result._tag === "Updated") return result
|
||||
return replaceObservation(entries, previous)
|
||||
const updates: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
const stored = get(previous, entry.key)
|
||||
if (entry.observed === unavailable) {
|
||||
// The prior belief stands while the source cannot be observed.
|
||||
if (stored) applied[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
if (!stored) {
|
||||
updates.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
continue
|
||||
}
|
||||
const text = entry.observed.update(stored)
|
||||
if (text === undefined) {
|
||||
applied[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
updates.push(text)
|
||||
applied[entry.key] = entry.observed.applied
|
||||
}
|
||||
const keys = new Set<string>(entries.map((entry) => entry.key))
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(key)) continue
|
||||
const removed = previous[key].removed
|
||||
// An unannounced removal retains the belief; it clears at the next rebaseline.
|
||||
if (removed === undefined) applied[key] = previous[key]
|
||||
else updates.push(removed)
|
||||
}
|
||||
if (updates.length === 0) return { _tag: "Unchanged" }
|
||||
return { _tag: "Updated", text: render(updates), applied }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function reconcileObservation(
|
||||
entries: ReadonlyArray<Entry>,
|
||||
previous: Snapshot,
|
||||
): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } {
|
||||
const keys = new Set(entries.map((entry) => entry.key))
|
||||
const comparisons = new Map<Key, Compared>()
|
||||
for (const entry of entries) {
|
||||
if (entry._tag === "Unavailable") continue
|
||||
const stored = getSnapshot(previous, entry.key)
|
||||
if (!stored) continue
|
||||
const compared = entry.compare(stored.value)
|
||||
if (compared._tag === "Incompatible") return { _tag: "Replace" }
|
||||
comparisons.set(entry.key, compared)
|
||||
}
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(Key.make(key))) continue
|
||||
if (previous[key].removed === undefined) return { _tag: "Replace" }
|
||||
}
|
||||
|
||||
const snapshot: Record<string, SourceSnapshot> = {}
|
||||
const updates: string[] = []
|
||||
for (const entry of entries) {
|
||||
const stored = getSnapshot(previous, entry.key)
|
||||
if (entry._tag === "Unavailable") {
|
||||
if (stored) snapshot[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
if (!stored) {
|
||||
const rendered = entry.baseline()
|
||||
updates.push(rendered.text)
|
||||
snapshot[entry.key] = rendered.snapshot
|
||||
continue
|
||||
}
|
||||
const compared = comparisons.get(entry.key)
|
||||
if (!compared || compared._tag === "Incompatible")
|
||||
throw new Error(`Missing comparison for system context source ${entry.key}`)
|
||||
if (compared._tag === "Unchanged") {
|
||||
snapshot[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
const rendered = compared.render()
|
||||
updates.push(rendered.text)
|
||||
snapshot[entry.key] = rendered.snapshot
|
||||
}
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(Key.make(key))) continue
|
||||
const removed = previous[key].removed
|
||||
if (removed === undefined) throw new Error(`Missing removal rendering for system context source ${key}`)
|
||||
updates.push(removed)
|
||||
}
|
||||
if (updates.length === 0) return { _tag: "Unchanged" }
|
||||
return { _tag: "Updated", text: render(updates), snapshot }
|
||||
}
|
||||
|
||||
/** Creates a complete replacement generation or blocks while admitted context is unavailable. */
|
||||
export function replace(value: SystemContext, previous: Snapshot): Effect.Effect<ReplacementResult> {
|
||||
return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous)))
|
||||
}
|
||||
|
||||
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
|
||||
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
|
||||
return { _tag: "ReplacementBlocked" }
|
||||
return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
|
||||
/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */
|
||||
export function rebaseline(value: SystemContext, previous: Applied): Effect.Effect<Baseline> {
|
||||
return observe(value).pipe(
|
||||
Effect.map((entries): Baseline => {
|
||||
const parts: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
if (entry.observed !== unavailable) {
|
||||
parts.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
continue
|
||||
}
|
||||
const stored = get(previous, entry.key)
|
||||
if (!stored) continue
|
||||
const text = entry.recall(stored)
|
||||
// An undecodable belief cannot be restated; the source re-announces when observable again.
|
||||
if (text === undefined) continue
|
||||
parts.push(text)
|
||||
applied[entry.key] = stored
|
||||
}
|
||||
return { text: render(parts), applied }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
|
||||
@@ -298,8 +285,8 @@ function render(parts: ReadonlyArray<string>) {
|
||||
return parts.join("\n\n")
|
||||
}
|
||||
|
||||
function getSnapshot(snapshot: Snapshot, key: Key) {
|
||||
return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined
|
||||
function get(applied: Applied, key: Key) {
|
||||
return Object.hasOwn(applied, key) ? applied[key] : undefined
|
||||
}
|
||||
|
||||
function isUnavailable(value: unknown): value is Unavailable {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
export * as SystemContextRegistry from "./registry"
|
||||
|
||||
import { Context, Effect, Layer, Ref, Scope } from "effect"
|
||||
import { SystemContext } from "./index"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
||||
export interface Entry {
|
||||
readonly key: SystemContext.Key
|
||||
readonly load: Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextRegistry") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
|
||||
yield* Effect.acquireRelease(
|
||||
Ref.modify(entries, (current) => {
|
||||
if (current.some((item) => item.key === entry.key)) return [false, current]
|
||||
return [true, [...current, entry]]
|
||||
}).pipe(
|
||||
Effect.flatMap((added) =>
|
||||
added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
|
||||
),
|
||||
Effect.as(entry),
|
||||
),
|
||||
(entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
|
||||
)
|
||||
}),
|
||||
load: Effect.fn("SystemContextRegistry.load")(function* () {
|
||||
const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||
return SystemContext.combine(
|
||||
yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
@@ -0,0 +1,92 @@
|
||||
export * as ToolHooks from "./hooks"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { State } from "../state"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm"
|
||||
|
||||
export interface BeforeEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
input: unknown
|
||||
}
|
||||
|
||||
export interface AfterEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
readonly input: unknown
|
||||
result: ToolResultValue
|
||||
output?: ToolOutput
|
||||
outputPaths?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly hook: {
|
||||
readonly before: (
|
||||
callback: (event: BeforeEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly after: (
|
||||
callback: (event: AfterEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
}
|
||||
readonly runBefore: (event: BeforeEvent) => Effect.Effect<BeforeEvent>
|
||||
readonly runAfter: (event: AfterEvent) => Effect.Effect<AfterEvent>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolHooks") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let beforeHooks: ((event: BeforeEvent) => Effect.Effect<void> | void)[] = []
|
||||
let afterHooks: ((event: AfterEvent) => Effect.Effect<void> | void)[] = []
|
||||
|
||||
const register = <Event>(
|
||||
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
|
||||
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
|
||||
) =>
|
||||
Effect.fn("ToolHooks.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
|
||||
const scope = yield* Scope.Scope
|
||||
let active = true
|
||||
update([...hooks(), callback])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
update(hooks().filter((item) => item !== callback))
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <Event>(
|
||||
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
|
||||
event: Event,
|
||||
) {
|
||||
for (const hook of hooks) {
|
||||
const result = hook(event)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
hook: {
|
||||
before: register(() => beforeHooks, (next) => (beforeHooks = next)),
|
||||
after: register(() => afterHooks, (next) => (afterHooks = next)),
|
||||
},
|
||||
runBefore: (event) => run(beforeHooks, event),
|
||||
runAfter: (event) => run(afterHooks, event),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
@@ -1,12 +1,16 @@
|
||||
export * as ReadTool from "./read"
|
||||
|
||||
import { dirname } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Image } from "../image"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { ReadToolFileSystem } from "./read-filesystem"
|
||||
import { ToolRegistry } from "./registry"
|
||||
@@ -14,6 +18,7 @@ import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "read"
|
||||
const FILENAME = "AGENTS.md"
|
||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||
const LocationInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
@@ -34,6 +39,9 @@ const layer = Layer.effectDiscard(
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const image = yield* Image.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
@@ -77,12 +85,33 @@ const layer = Layer.effectDiscard(
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (type === "directory")
|
||||
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
const content = yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
const resolved = FSUtil.resolve(target.canonical)
|
||||
const root = FSUtil.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by the core/instructions baseline) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void))
|
||||
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||
return yield* image
|
||||
.normalize(resource, { ...content, encoding: "base64" })
|
||||
@@ -113,5 +142,14 @@ const layer = Layer.effectDiscard(
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/read",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
|
||||
deps: [
|
||||
ToolRegistry.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
Image.node,
|
||||
PermissionV2.node,
|
||||
SessionInstructions.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
||||
export type ExecuteInput = {
|
||||
@@ -47,6 +48,7 @@ const registryLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
@@ -61,7 +63,17 @@ const registryLayer = Layer.effect(
|
||||
}
|
||||
if (advertised && registration.identity !== advertised)
|
||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||
const pending = yield* settle(registration.tool, input.call, {
|
||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
|
||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
input: input.call.input,
|
||||
}
|
||||
yield* toolHooks.runBefore(beforeEvent)
|
||||
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
@@ -72,15 +84,38 @@ const registryLayer = Layer.effect(
|
||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||
),
|
||||
)
|
||||
if ("result" in pending) return pending
|
||||
const output = pending.output
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
if (result.type === "error")
|
||||
return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
|
||||
return bounded.outputPaths.length > 0
|
||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||
: { result, output: bounded.output }
|
||||
let settlement: Settlement
|
||||
if ("result" in pending) {
|
||||
settlement = pending
|
||||
} else {
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
settlement =
|
||||
result.type === "error"
|
||||
? bounded.outputPaths.length > 0
|
||||
? { result, outputPaths: bounded.outputPaths }
|
||||
: { result }
|
||||
: bounded.outputPaths.length > 0
|
||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||
: { result, output: bounded.output }
|
||||
}
|
||||
const afterEvent: ToolHooks.AfterEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
input: beforeEvent.input,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
outputPaths: settlement.outputPaths,
|
||||
}
|
||||
yield* toolHooks.runAfter(afterEvent)
|
||||
return {
|
||||
result: afterEvent.result,
|
||||
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
||||
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -143,11 +178,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
})
|
||||
|
||||
@@ -173,6 +173,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
|
||||
})
|
||||
expect(reviewer.request).toEqual({
|
||||
settings: {},
|
||||
headers: { first: "one", shared: "last", second: "two" },
|
||||
body: { enabled: true, profile: "review", retries: 2, effort: "high" },
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -22,7 +21,7 @@ const instructionLayer = (input: {
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
}) =>
|
||||
AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [
|
||||
AppNodeBuilder.build(InstructionContext.node, [
|
||||
[Global.node, Global.layerWith({ config: input.config })],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
@@ -52,7 +51,7 @@ describe("InstructionContext", () => {
|
||||
await fs.writeFile(packageFile, "package")
|
||||
})
|
||||
|
||||
const load = SystemContextRegistry.Service.pipe(
|
||||
const load = InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -71,23 +70,23 @@ describe("InstructionContext", () => {
|
||||
)
|
||||
|
||||
const initialized = yield* SystemContext.initialize(yield* load)
|
||||
expect(initialized.baseline).toBe(
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${packageFile}\npackage`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
expect(initialized.baseline).not.toContain("outside")
|
||||
expect(initialized.text).not.toContain("outside")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => fs.rm(packageFile))
|
||||
const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot)
|
||||
const partial = yield* SystemContext.reconcile(yield* load, initialized.applied)
|
||||
expect(partial).toEqual({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
@@ -95,14 +94,14 @@ describe("InstructionContext", () => {
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
].join("\n\n"),
|
||||
snapshot: expect.any(Object),
|
||||
applied: expect.any(Object),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Previously loaded instructions no longer apply.",
|
||||
snapshot: {},
|
||||
applied: {},
|
||||
})
|
||||
}),
|
||||
),
|
||||
@@ -118,7 +117,7 @@ describe("InstructionContext", () => {
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "AGENTS.md")
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -131,7 +130,7 @@ describe("InstructionContext", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`)
|
||||
expect((yield* SystemContext.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -147,7 +146,7 @@ describe("InstructionContext", () => {
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -187,7 +186,7 @@ describe("InstructionContext", () => {
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -231,7 +230,7 @@ describe("InstructionContext", () => {
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -261,7 +260,7 @@ describe("InstructionContext", () => {
|
||||
let scanned = false
|
||||
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
|
||||
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -293,7 +292,7 @@ describe("InstructionContext", () => {
|
||||
it.effect("does not discover project instructions outside the canonical project root", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -102,4 +104,68 @@ describe("PluginV2", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const executed: unknown[] = []
|
||||
const seen: {
|
||||
before?: unknown
|
||||
after?: { input: unknown; result: unknown; output: unknown }
|
||||
} = {}
|
||||
|
||||
const plugin = define({
|
||||
id: "tool-hooks",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.tool.execute
|
||||
.before((event) => {
|
||||
seen.before = event.input
|
||||
event.input = { text: "before-mutated" }
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
|
||||
yield* ctx.tool.execute
|
||||
.after((event) => {
|
||||
seen.after = { input: event.input, result: event.result, output: event.output }
|
||||
event.result = { type: "text", value: "after-mutated" }
|
||||
event.output = { structured: { rewritten: true }, content: [] }
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
|
||||
const materialized = yield* registry.materialize({ model: testModel })
|
||||
const settlement = yield* materialized.settle({
|
||||
sessionID: SessionV2.ID.make("ses_hooks"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_hooks"),
|
||||
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
|
||||
})
|
||||
|
||||
expect(seen.before).toEqual({ text: "original" })
|
||||
expect(executed).toEqual([{ text: "before-mutated" }])
|
||||
expect(seen.after).toEqual({
|
||||
input: { text: "before-mutated" },
|
||||
result: { type: "json", value: { text: "before-mutated" } },
|
||||
output: { structured: { text: "before-mutated" }, content: [] },
|
||||
})
|
||||
expect(settlement.result).toEqual({ type: "text", value: "after-mutated" })
|
||||
expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
@@ -47,6 +48,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
PluginRuntime.node,
|
||||
Reference.node,
|
||||
SkillV2.node,
|
||||
ToolHooks.node,
|
||||
ToolRegistry.toolsNode,
|
||||
]),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"openai": {
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"env": ["OPENAI_API_KEY"],
|
||||
"npm": "@ai-sdk/openai",
|
||||
"api": "https://api.openai.com/v1",
|
||||
"models": {
|
||||
"gpt-reasoning": {
|
||||
"id": "gpt-reasoning",
|
||||
"name": "GPT Reasoning",
|
||||
"release_date": "2026-01-01",
|
||||
"attachment": false,
|
||||
"reasoning": true,
|
||||
"reasoning_options": [
|
||||
{ "type": "effort", "values": ["low", "high"] },
|
||||
{ "type": "budget_tokens", "min": 1024, "max": 64000 },
|
||||
{ "type": "toggle" }
|
||||
],
|
||||
"temperature": true,
|
||||
"tool_call": true,
|
||||
"limit": { "context": 128000, "output": 8192 },
|
||||
"experimental": {
|
||||
"modes": {
|
||||
"high": {
|
||||
"provider": {
|
||||
"headers": { "x-mode": "high" },
|
||||
"body": { "service_tier": "priority" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"anthropic": {
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"env": ["ANTHROPIC_API_KEY"],
|
||||
"npm": "@ai-sdk/anthropic",
|
||||
"api": "https://api.anthropic.com/v1",
|
||||
"models": {
|
||||
"claude-budget": {
|
||||
"id": "claude-budget",
|
||||
"name": "Claude Budget",
|
||||
"release_date": "2026-01-01",
|
||||
"attachment": false,
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 64000 }],
|
||||
"temperature": true,
|
||||
"tool_call": true,
|
||||
"limit": { "context": 128000, "output": 8192 }
|
||||
},
|
||||
"claude-effort": {
|
||||
"id": "claude-effort",
|
||||
"name": "Claude Effort",
|
||||
"release_date": "2026-01-01",
|
||||
"attachment": false,
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{ "type": "effort", "values": ["low"] }],
|
||||
"temperature": true,
|
||||
"tool_call": true,
|
||||
"limit": { "context": 128000, "output": 8192 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,10 @@ export function host(overrides: Overrides = {}): PluginContext {
|
||||
},
|
||||
tool: overrides.tool ?? {
|
||||
register: () => Effect.die("unused tool.register"),
|
||||
execute: {
|
||||
before: () => Effect.die("unused tool.execute.before"),
|
||||
after: () => Effect.die("unused tool.execute.after"),
|
||||
},
|
||||
},
|
||||
session: overrides.session ?? {
|
||||
create: () => Effect.die("unused session.create"),
|
||||
@@ -275,7 +279,11 @@ function agentInfo(value: AgentV2.Info) {
|
||||
return {
|
||||
...value,
|
||||
model: value.model && { ...value.model },
|
||||
request: { headers: { ...value.request.headers }, body: { ...value.request.body } },
|
||||
request: {
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
permissions: value.permissions.map((permission) => ({ ...permission })),
|
||||
}
|
||||
}
|
||||
@@ -284,7 +292,11 @@ function providerInfo(value: ProviderV2.MutableInfo) {
|
||||
return {
|
||||
...value,
|
||||
api: { ...value.api, settings: value.api.settings && { ...value.api.settings } },
|
||||
request: { headers: { ...value.request.headers }, body: { ...value.request.body } },
|
||||
request: {
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,11 +311,13 @@ function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) {
|
||||
},
|
||||
request: {
|
||||
...value.request,
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
variants: value.variants.map((variant) => ({
|
||||
...variant,
|
||||
settings: { ...variant.settings },
|
||||
headers: { ...variant.headers },
|
||||
body: { ...variant.body },
|
||||
})),
|
||||
|
||||
@@ -168,14 +168,14 @@ describe("ModelsDevPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("derives OpenAI reasoning variants from models.dev reasoning options", () =>
|
||||
it.effect("converts reasoning options into settings variants", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = {
|
||||
path: Flag.OPENCODE_MODELS_PATH,
|
||||
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
|
||||
}
|
||||
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
|
||||
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
||||
return previous
|
||||
}),
|
||||
@@ -183,17 +183,6 @@ describe("ModelsDevPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"), (model) => {
|
||||
model.variants = [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
headers: { custom: "true" },
|
||||
body: { custom: true },
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
@@ -201,42 +190,67 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5")))?.variants).toEqual([
|
||||
{
|
||||
id: ModelV2.VariantID.make("none"),
|
||||
headers: {},
|
||||
body: {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "none", summary: "auto" },
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
id: "low",
|
||||
body: {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "low", summary: "auto" },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "medium",
|
||||
body: {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "medium", summary: "auto" },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "high",
|
||||
headers: { custom: "true" },
|
||||
body: { custom: true },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "xhigh",
|
||||
body: {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "xhigh", summary: "auto" },
|
||||
},
|
||||
}),
|
||||
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
|
||||
expect(model?.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("low"),
|
||||
ModelV2.VariantID.make("high"),
|
||||
])
|
||||
expect(model?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("low"),
|
||||
settings: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
expect(model?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
|
||||
expect(mode).toMatchObject({
|
||||
id: "gpt-reasoning-high",
|
||||
name: "GPT Reasoning High",
|
||||
request: {
|
||||
headers: { "x-mode": "high" },
|
||||
body: { service_tier: "priority" },
|
||||
},
|
||||
})
|
||||
expect(mode?.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("low"),
|
||||
ModelV2.VariantID.make("high"),
|
||||
])
|
||||
|
||||
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
|
||||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("max"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 64000 } },
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-effort"))
|
||||
expect(anthropicEffortModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("low"),
|
||||
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
@@ -245,5 +259,4 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
})
|
||||
catalog.provider.update(bedrock.id, (item) => {
|
||||
item.api = bedrock.api
|
||||
item.request = bedrock.request
|
||||
item.request = { settings: {}, headers: {}, body: { endpoint: "https://bedrock.example" } }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("AnthropicPlugin", () => {
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
draft.request = { settings: {}, headers: { Existing: "1" }, body: {} }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: "from-config" } }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
})
|
||||
@@ -110,7 +110,7 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: "" } }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
@@ -131,7 +131,7 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: " " } }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("KiloPlugin", () => {
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/api/gateway",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("LLMGatewayPlugin", () => {
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.llmgateway.io/v1",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("NvidiaPlugin", () => {
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
@@ -80,6 +80,7 @@ describe("NvidiaPlugin", () => {
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = {
|
||||
settings: {},
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
@@ -27,6 +28,20 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
@@ -153,6 +168,80 @@ describe("OpenAIPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
|
||||
model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }]
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const eligible = required(
|
||||
yield* eventually(
|
||||
catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")),
|
||||
(model) => model?.cost.length === 0,
|
||||
),
|
||||
)
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the full OpenAI catalog under an API key connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
value: Credential.Key.make({ type: "key", key: "sk-test" }),
|
||||
})
|
||||
yield* addPlugin()
|
||||
// The connection refresh is asynchronous; give it time to settle before
|
||||
// asserting nothing was filtered.
|
||||
yield* Effect.promise(() => Bun.sleep(25))
|
||||
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -142,6 +142,7 @@ describe("OpencodePlugin", () => {
|
||||
model.variants = [
|
||||
{
|
||||
id: ModelV2.VariantID.make("custom"),
|
||||
settings: {},
|
||||
headers: { "x-custom": "true" },
|
||||
body: { custom: true },
|
||||
},
|
||||
@@ -177,7 +178,7 @@ describe("OpencodePlugin", () => {
|
||||
url: `${server.url.origin}/v1`,
|
||||
},
|
||||
})
|
||||
expect(provider.request).toEqual({ headers: { "x-org-id": "org" }, body: { custom: "value" } })
|
||||
expect(provider.request).toEqual({ settings: {}, headers: { "x-org-id": "org" }, body: { custom: "value" } })
|
||||
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
|
||||
|
||||
const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
|
||||
@@ -192,11 +193,13 @@ describe("OpencodePlugin", () => {
|
||||
expect(model.variants).toEqual([
|
||||
{
|
||||
id: ModelV2.VariantID.make("custom"),
|
||||
settings: {},
|
||||
headers: { "x-custom": "true" },
|
||||
body: { custom: true },
|
||||
},
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: { temperature: 0.2 },
|
||||
},
|
||||
@@ -359,6 +362,7 @@ describe("OpencodePlugin", () => {
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
request: {
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: { apiKey: "configured" },
|
||||
},
|
||||
@@ -369,7 +373,7 @@ describe("OpencodePlugin", () => {
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, (draft) => {
|
||||
draft.request = provider.request
|
||||
draft.request = { settings: {}, headers: {}, body: { apiKey: "configured" } }
|
||||
})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("OpenRouterPlugin", () => {
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||
})
|
||||
|
||||
@@ -37,8 +37,8 @@ describe("VariantPlugin", () => {
|
||||
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
|
||||
|
||||
expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([
|
||||
expect.objectContaining({ id: "high", body: { reasoning_effort: "high" } }),
|
||||
expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }),
|
||||
expect.objectContaining({ id: "high", settings: { reasoningEffort: "high" } }),
|
||||
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -53,14 +53,14 @@ describe("VariantPlugin", () => {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
}
|
||||
model.variants = [{ id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} }]
|
||||
model.variants = [{ id: ModelV2.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }]
|
||||
})
|
||||
})
|
||||
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
|
||||
|
||||
expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([
|
||||
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
|
||||
expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }),
|
||||
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -16,10 +16,10 @@ describe("ReferenceGuidance", () => {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
|
||||
expect(generation.baseline).toContain("<available_references>")
|
||||
expect(generation.baseline).toContain("<name>docs</name>")
|
||||
expect(generation.baseline).toContain("<path>/docs</path>")
|
||||
expect(generation.baseline).toContain("<description>Use for product documentation</description>")
|
||||
expect(generation.text).toContain("<available_references>")
|
||||
expect(generation.text).toContain("<name>docs</name>")
|
||||
expect(generation.text).toContain("<path>/docs</path>")
|
||||
expect(generation.text).toContain("<description>Use for product documentation</description>")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
guidanceLayer(
|
||||
@@ -47,7 +47,7 @@ describe("ReferenceGuidance", () => {
|
||||
Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
expect(generation.baseline).toBe("")
|
||||
expect(generation.text).toBe("")
|
||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("ReferenceGuidance", () => {
|
||||
Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
expect(generation.baseline).toBe("")
|
||||
expect(generation.text).toBe("")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
guidanceLayer(
|
||||
@@ -73,4 +73,41 @@ describe("ReferenceGuidance", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("announces added and removed references as deltas", () => {
|
||||
const reference = (name: string, description: string) =>
|
||||
new Reference.Info({
|
||||
name,
|
||||
path: AbsolutePath.make(`/${name}`),
|
||||
description,
|
||||
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make(`/${name}`), description }),
|
||||
})
|
||||
let references = [reference("docs", "Use for product documentation")]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* guidance.load())
|
||||
|
||||
references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")]
|
||||
const added = yield* SystemContext.reconcile(yield* guidance.load(), initialized.applied)
|
||||
expect(added).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
"New project references are available in addition to those previously listed:",
|
||||
" <reference>",
|
||||
" <name>examples</name>",
|
||||
" <path>/examples</path>",
|
||||
" <description>Use for examples</description>",
|
||||
" </reference>",
|
||||
].join("\n"),
|
||||
})
|
||||
|
||||
references = [reference("examples", "Use for examples")]
|
||||
expect(
|
||||
yield* SystemContext.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following project references are no longer available and must not be used: docs.",
|
||||
})
|
||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) }))))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,11 +81,20 @@ describe("SessionV2.compact", () => {
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prompt = Prompt.make({ text: "Please compact this session history." })
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID: created.id,
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt,
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID: created.id,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt: Prompt.make({ text: "Please compact this session history." }),
|
||||
prompt,
|
||||
delivery: "steer",
|
||||
})
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("SessionV2.create", () => {
|
||||
prompt: Prompt.make({ text: "First" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: parent.id,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
@@ -167,9 +167,9 @@ describe("SessionV2.create", () => {
|
||||
})
|
||||
|
||||
yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, forked.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, forked.id)
|
||||
|
||||
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||
@@ -192,13 +192,13 @@ describe("SessionV2.create", () => {
|
||||
prompt: Prompt.make({ text: "First" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
const second = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
prompt: Prompt.make({ text: "Second" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
|
||||
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("SessionV2.create", () => {
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, created.id)
|
||||
|
||||
expect(
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
||||
@@ -336,7 +336,7 @@ describe("SessionV2.create", () => {
|
||||
prompt: Prompt.make({ text: "Replay lifecycle" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id)
|
||||
const serialized = (yield* sourceDb
|
||||
.select()
|
||||
.from(EventTable)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { Message, Model } from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/read"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { settleTool, testModel } from "./lib/tool"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: () => Effect.void,
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
|
||||
const testLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
SessionV2.node,
|
||||
Location.node,
|
||||
FSUtil.node,
|
||||
LocationMutation.node,
|
||||
ReadToolFileSystem.node,
|
||||
ReadTool.node,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
ToolHooks.node,
|
||||
SessionInstructions.node,
|
||||
Global.node,
|
||||
ToolOutputStore.node,
|
||||
Image.node,
|
||||
]),
|
||||
[
|
||||
[ProjectV2.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[Location.node, tempLocationLayer],
|
||||
[PermissionV2.node, permission],
|
||||
[Config.node, config],
|
||||
[Image.node, imageLayer],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
],
|
||||
) as unknown as Layer.Layer<unknown>
|
||||
|
||||
const it = testEffect(testLayer)
|
||||
|
||||
const identity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_nearby"),
|
||||
}
|
||||
const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id, name: "read", input: { path: readPath } },
|
||||
})
|
||||
|
||||
const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content))
|
||||
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
|
||||
const synthetics = (sessionID: SessionV2.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
return (yield* store.context(sessionID)).filter((message) => message.type === "synthetic")
|
||||
})
|
||||
|
||||
// Seed a prior synthetic message with an instruction dedup ledger, simulating a prior turn
|
||||
// after the Location layer was reopened (in-memory set empty).
|
||||
const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
text: `Instructions from: ${paths[0]}\nprior`,
|
||||
description: `Loaded ${paths[0]}`,
|
||||
metadata: { instruction: { paths } },
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionInstructions", () => {
|
||||
it.effect("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
const deepPath = path.resolve(dir, "sub", "deep", "AGENTS.md")
|
||||
const otherPath = path.resolve(dir, "sub", "other", "AGENTS.md")
|
||||
yield* mkdir(path.dirname(deepPath))
|
||||
yield* mkdir(path.dirname(otherPath))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
yield* writeAgents(deepPath, "deep-instructions")
|
||||
yield* writeAgents(otherPath, "other-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2"))
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
|
||||
// excluding the Location root (already supplied by the core/instructions baseline).
|
||||
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
|
||||
|
||||
const firstInjected = yield* synthetics(sessionID)
|
||||
expect(firstInjected).toHaveLength(1)
|
||||
expect(firstInjected[0]!.text).toBe(
|
||||
`Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`,
|
||||
)
|
||||
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`)
|
||||
// The synthetic's metadata carries the durable dedup ledger.
|
||||
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
|
||||
expect(firstInjected[0]!.text).not.toContain("root-instructions")
|
||||
|
||||
// A sibling read under sub/other discovers only the new AGENTS.md; sub is already
|
||||
// injected for this session so it is not re-emitted, and the root is still excluded.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
|
||||
|
||||
const secondInjected = yield* synthetics(sessionID)
|
||||
expect(secondInjected).toHaveLength(2)
|
||||
expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`)
|
||||
expect(secondInjected[1]!.description).toBe(`Loaded ${path.relative(dir, otherPath)}`)
|
||||
expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } })
|
||||
expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not re-inject paths already recorded in durable session history", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "sub"))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
// Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
|
||||
// via the instruction metadata ledger.
|
||||
yield* seedSynthetic(sessionID, [subPath])
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||
|
||||
yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
|
||||
|
||||
// The durable claim on the prior synthetic prevents re-injection; no new synthetic.
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "packages", "foo"))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(pkgPath, "pkg-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
|
||||
// the Location root (already supplied by the core/instructions baseline).
|
||||
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
|
||||
|
||||
const firstInjected = yield* synthetics(sessionID)
|
||||
expect(firstInjected).toHaveLength(1)
|
||||
expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
|
||||
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
|
||||
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
|
||||
expect(firstInjected[0]!.text).not.toContain("root-instructions")
|
||||
|
||||
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
|
||||
// already injected for this session, so nothing new is emitted.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
|
||||
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("listing the Location root directory injects no instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "sub"))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
// The walk starts and stops at the Location root: the root AGENTS.md is searched but
|
||||
// dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
|
||||
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads instructions directly without a read", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "sub"))
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
yield* sessionInstructions.load({ sessionID, paths: [subPath] })
|
||||
|
||||
const injected = yield* synthetics(sessionID)
|
||||
expect(injected).toHaveLength(1)
|
||||
expect(injected[0]!.text).toBe(`Instructions from: ${subPath}\nsub-instructions`)
|
||||
expect(injected[0]!.description).toBe(`Loaded ${path.relative(dir, subPath)}`)
|
||||
expect(injected[0]!.metadata).toEqual({ instruction: { paths: [subPath] } })
|
||||
}),
|
||||
)
|
||||
|
||||
test("toLLMMessages does not forward synthetic metadata to the provider", () => {
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||
const synthetic = SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.make("msg_synthetic"),
|
||||
type: "synthetic",
|
||||
sessionID: SessionV2.ID.make("ses_test"),
|
||||
text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
|
||||
description: "Loaded /repo/sub/AGENTS.md",
|
||||
metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },
|
||||
time: { created },
|
||||
})
|
||||
const messages = toLLMMessages([synthetic], model)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]!.role).toBe("user")
|
||||
expect(messages[0]!.content).toEqual([{ type: "text", text: "Instructions from: /repo/sub/AGENTS.md\ncontent" }])
|
||||
// Metadata is bookkeeping for the dedup ledger; the model must not see it.
|
||||
expect(messages[0]!.metadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,12 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import {
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
||||
@@ -67,6 +72,10 @@ describe("SessionProjector", () => {
|
||||
.insert(SessionMessageTable)
|
||||
.values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)])
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 })
|
||||
.run()
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
@@ -93,6 +102,8 @@ describe("SessionProjector", () => {
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
).toEqual([boundary])
|
||||
// A committed revert resets the context checkpoint so the next turn re-initializes.
|
||||
expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -118,6 +129,13 @@ describe("SessionProjector", () => {
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_first"),
|
||||
timestamp: created,
|
||||
prompt: Prompt.make({ text: "first" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{
|
||||
@@ -129,6 +147,13 @@ describe("SessionProjector", () => {
|
||||
},
|
||||
{ id: EventV2.ID.make("evt_z") },
|
||||
)
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_second"),
|
||||
timestamp: created,
|
||||
prompt: Prompt.make({ text: "second" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{
|
||||
@@ -248,6 +273,7 @@ describe("SessionProjector", () => {
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
sessionID,
|
||||
@@ -318,6 +344,10 @@ describe("SessionProjector", () => {
|
||||
"shell",
|
||||
"compaction",
|
||||
])
|
||||
expect(messages.find((message) => message.type === "synthetic")).toMatchObject({
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
output: "/project",
|
||||
time: { completed: DateTime.makeUnsafe(1) },
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("SessionV2.prompt", () => {
|
||||
prompt: Prompt.make({ text: "boundary" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
const stale = SessionMessage.ID.make("msg_stale_assistant")
|
||||
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
@@ -250,7 +250,7 @@ describe("SessionV2.prompt", () => {
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
|
||||
@@ -424,10 +424,7 @@ describe("SessionV2.prompt", () => {
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false })
|
||||
|
||||
yield* Effect.all(
|
||||
[
|
||||
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
|
||||
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
|
||||
],
|
||||
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
@@ -439,23 +436,6 @@ describe("SessionV2.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("promotes steers only through the captured inbox cutoff", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const first = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Before cutoff" }), resume: false })
|
||||
const cutoff = first.admittedSeq
|
||||
const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false })
|
||||
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
|
||||
|
||||
expect(yield* admitted(first.id)).toHaveProperty("promotedSeq")
|
||||
expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reprojects pending inbox input without scheduling execution", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
@@ -499,48 +479,6 @@ describe("SessionV2.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an exact retry of a legacy projected prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = Prompt.make({ text: "Historical prompt" })
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
prompt,
|
||||
delivery: "steer",
|
||||
})
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
|
||||
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical prompt" } })
|
||||
expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an exact retry of a legacy projected queued prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = Prompt.make({ text: "Historical queued prompt" })
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
})
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
|
||||
|
||||
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical queued prompt" } })
|
||||
expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reuse of one globally unique message ID across sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -30,6 +30,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
||||
api: { id: ModelV2.ID.make("api-test-model"), ...api },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
request: {
|
||||
settings: {},
|
||||
headers: { "x-test": "header" },
|
||||
body: { apiKey: "secret", custom_extension: { enabled: true } },
|
||||
},
|
||||
@@ -83,7 +84,7 @@ describe("SessionRunnerModel", () => {
|
||||
url: "https://compatible.example/v1",
|
||||
settings: { apiKey: "settings-secret", compatibility: "strict" },
|
||||
}),
|
||||
request: { headers: {}, body: {} },
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
@@ -100,17 +101,17 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected OpenAI Session variant bodies", () =>
|
||||
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { reasoningEffort: "high" },
|
||||
headers: { "x-variant": "high" },
|
||||
body: {
|
||||
store: false,
|
||||
service_tier: "priority",
|
||||
temperature: 0.2,
|
||||
reasoning: { effort: "high" },
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -137,7 +138,9 @@ describe("SessionRunnerModel", () => {
|
||||
store: false,
|
||||
service_tier: "priority",
|
||||
temperature: 0.2,
|
||||
reasoning: { effort: "high" },
|
||||
})
|
||||
expect(resolved.route.defaults.providerOptions).toEqual({
|
||||
openai: { store: false, reasoningEffort: "high" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -149,6 +152,7 @@ describe("SessionRunnerModel", () => {
|
||||
[
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: { store: false, reasoning_effort: "high" },
|
||||
},
|
||||
@@ -205,13 +209,14 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected Anthropic Session variant bodies", () =>
|
||||
it.effect("overlays selected Anthropic Session variant settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
|
||||
headers: {},
|
||||
body: { thinking: { type: "enabled", budget_tokens: 12000 } },
|
||||
body: {},
|
||||
},
|
||||
])
|
||||
const session = SessionV2.Info.make({
|
||||
@@ -229,7 +234,9 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
expect(resolved.route.defaults.http?.body).toEqual({
|
||||
custom_extension: { enabled: true },
|
||||
thinking: { type: "enabled", budget_tokens: 12000 },
|
||||
})
|
||||
expect(resolved.route.defaults.providerOptions).toEqual({
|
||||
anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -252,7 +259,7 @@ describe("SessionRunnerModel", () => {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
)
|
||||
@@ -275,7 +282,7 @@ describe("SessionRunnerModel", () => {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: { apiKey: "configured-secret" } },
|
||||
request: { settings: {}, headers: {}, body: { apiKey: "configured-secret" } },
|
||||
}),
|
||||
credential,
|
||||
)
|
||||
@@ -297,7 +304,7 @@ describe("SessionRunnerModel", () => {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
@@ -313,6 +320,101 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "openai-responses",
|
||||
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
||||
})
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-headless"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "oauth-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://openai.example/v1/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
|
||||
expect(headers.authorization).toBe("Bearer oauth-token")
|
||||
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects catalog APIs without a native route", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||
|
||||
@@ -31,7 +31,8 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
@@ -72,7 +73,8 @@ const model = OpenAIChat.route
|
||||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||
const systemContext = AppNodeBuilder.build(SystemContextRegistry.node)
|
||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
@@ -81,7 +83,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
@@ -116,7 +119,8 @@ const it = testEffect(
|
||||
AgentV2.node,
|
||||
ToolRegistry.node,
|
||||
SessionRunnerModel.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
@@ -129,7 +133,8 @@ const it = testEffect(
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
|
||||
@@ -25,7 +25,6 @@ import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
@@ -46,14 +45,15 @@ import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import {
|
||||
SessionContextEpochTable,
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
@@ -154,6 +154,14 @@ const echo = Layer.effectDiscard(
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected tool defect"),
|
||||
}),
|
||||
// BigInt output with no model content forces ToolOutputStore.bound onto its
|
||||
// JSON.stringify encode path, which fails with a typed StorageError.
|
||||
storefail: Tool.make({
|
||||
description: "Produce output that cannot be persisted",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Any,
|
||||
execute: () => Effect.succeed({ big: 1n }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -169,35 +177,28 @@ let systemRemoved = false
|
||||
let systemUnavailable = false
|
||||
let systemLoadHook = Effect.void
|
||||
const skillBaselines = new Map<AgentV2.ID, string>()
|
||||
const systemContext = Layer.effectDiscard(
|
||||
SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((registry) =>
|
||||
registry.register({
|
||||
key: systemContextKey,
|
||||
load: Effect.sync(() =>
|
||||
SystemContext.combine(
|
||||
systemRemoved
|
||||
? []
|
||||
: [
|
||||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: systemLoadHook.pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
||||
),
|
||||
),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "System context source removed: test/context",
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
}),
|
||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, {
|
||||
load: () =>
|
||||
Effect.sync(() =>
|
||||
SystemContext.combine(
|
||||
systemRemoved
|
||||
? []
|
||||
: [
|
||||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: systemLoadHook.pipe(
|
||||
Effect.andThen(Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline))),
|
||||
),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "System context source removed: test/context",
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node)))
|
||||
})
|
||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const skillGuidance = Layer.mock(SkillGuidance.Service, {
|
||||
load: (agent) =>
|
||||
Effect.succeed(
|
||||
@@ -236,7 +237,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
@@ -274,7 +276,8 @@ const it = testEffect(
|
||||
ToolRegistry.toolsNode,
|
||||
echoNode,
|
||||
SessionRunnerModel.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
@@ -287,7 +290,8 @@ const it = testEffect(
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[PermissionV2.node, permission],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
@@ -647,6 +651,7 @@ describe("SessionRunnerLLM", () => {
|
||||
response = []
|
||||
|
||||
const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) })
|
||||
yield* session.wait(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
@@ -671,7 +676,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
|
||||
expect(requests[0]?.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "First" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Second" }] },
|
||||
@@ -699,13 +704,14 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get(),
|
||||
).toBeUndefined()
|
||||
|
||||
systemUnavailable = false
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) })
|
||||
yield* session.wait(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
|
||||
@@ -731,8 +737,8 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get(),
|
||||
).toBeUndefined()
|
||||
|
||||
@@ -745,7 +751,36 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails gracefully when a stored context snapshot cannot be decoded", () =>
|
||||
it.effect("copies the context checkpoint to a fork", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const forked = yield* session.fork({ sessionID })
|
||||
|
||||
const parent = yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(parent).toBeDefined()
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, forked.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual({ ...parent!, session_id: forked.id })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("heals an undecodable stored applied record by re-announcing context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -754,19 +789,28 @@ describe("SessionRunnerLLM", () => {
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.update(SessionContextCheckpointTable)
|
||||
.set({ snapshot: { invalid: { value: "bad" } } })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
requests.length = 0
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(ContextSnapshotDecodeError)
|
||||
expect(requests).toHaveLength(0)
|
||||
// Comparison state was lost, so every source re-announces as new.
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[0]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Initial context" }])
|
||||
const healed = yield* db
|
||||
.select({ snapshot: SessionContextCheckpointTable.snapshot })
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(healed?.snapshot).toEqual({ "test/context": { value: "Initial context", removed: expect.any(String) } })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -787,8 +831,8 @@ describe("SessionRunnerLLM", () => {
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
@@ -1049,8 +1093,8 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.at(-1)?.content).toEqual([
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
||||
{ type: "text", text: "System context source removed: test/context" },
|
||||
])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
@@ -1085,15 +1129,15 @@ describe("SessionRunnerLLM", () => {
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"user",
|
||||
"system",
|
||||
"user",
|
||||
"model-switched",
|
||||
"user",
|
||||
"system",
|
||||
"user",
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
||||
@@ -1361,7 +1405,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
|
||||
it.effect("rebaselines after compaction from the last-applied belief while unobservable", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -1393,8 +1437,9 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
|
||||
// The rebaseline proceeds while the source is unobservable, restating the model's belief.
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
|
||||
expect(systemTexts(requests.at(-1)!)).not.toContain("Changed context")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1456,7 +1501,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Use tools" },
|
||||
{
|
||||
@@ -2292,7 +2337,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false })
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -2356,7 +2401,7 @@ describe("SessionRunnerLLM", () => {
|
||||
prompt: Prompt.make({ text: "Recover interrupted hosted tool" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -2416,7 +2461,7 @@ describe("SessionRunnerLLM", () => {
|
||||
prompt: Prompt.make({ text: "Recover interrupted tool input" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -2529,8 +2574,9 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
const second = yield* session.resume(otherSessionID).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Deferred.await(streamStarted)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([
|
||||
@@ -2700,6 +2746,49 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails the drain when tool output persistence fails", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call storefail" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-storefail", name: "storefail", input: {} }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Call storefail" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-storefail",
|
||||
state: {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: expect.stringContaining("Tool execution failed: Failed to encode tool output"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts runner continuation when a question is dismissed", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -39,7 +39,14 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, AgentV2.node, SessionTitle.node]),
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
AgentV2.node,
|
||||
SessionTitle.node,
|
||||
]),
|
||||
[
|
||||
[llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
@@ -74,9 +81,17 @@ const insertSession = (id: SessionV2.ID) =>
|
||||
const prompt = (sessionID: SessionV2.ID, text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const messageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt: Prompt.make({ text }),
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt: Prompt.make({ text }),
|
||||
delivery: "steer",
|
||||
@@ -99,9 +114,9 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
|
||||
)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
|
||||
@@ -129,9 +144,9 @@ it.effect("does not generate once a second user message exists", () =>
|
||||
yield* prompt(sessionID, "Second message")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
|
||||
)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
|
||||
@@ -177,9 +192,9 @@ it.effect("does not generate for a child session", () =>
|
||||
yield* prompt(sessionID, "Do this subtask")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
|
||||
)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
|
||||
@@ -195,9 +210,9 @@ it.effect("does not generate when the title agent is removed", () =>
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
|
||||
)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("SkillGuidance", () => {
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
|
||||
expect(initialized.baseline).toBe(
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
@@ -65,16 +65,82 @@ describe("SkillGuidance", () => {
|
||||
"</available_skills>",
|
||||
].join("\n"),
|
||||
)
|
||||
expect(initialized.baseline).not.toContain("manual")
|
||||
expect(initialized.text).not.toContain("manual")
|
||||
|
||||
skills = []
|
||||
expect(
|
||||
yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.snapshot))),
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining("No skills are currently available."),
|
||||
text: "The following skills are no longer available and must not be used: effect.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
|
||||
it.effect("announces added and removed skills as deltas without restating the list", () => {
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
|
||||
const debugging = SkillV2.Info.make({
|
||||
name: "debugging",
|
||||
description: "Diagnose hard bugs",
|
||||
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
|
||||
content: "Debugging guidance",
|
||||
})
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
const initialized = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
|
||||
skills = [effect, debugging]
|
||||
const added = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied)))
|
||||
expect(added).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
"New skills are available in addition to those previously listed:",
|
||||
" <skill>",
|
||||
" <name>debugging</name>",
|
||||
" <description>Diagnose hard bugs</description>",
|
||||
" </skill>",
|
||||
].join("\n"),
|
||||
})
|
||||
|
||||
skills = [debugging]
|
||||
const removed = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(
|
||||
Effect.flatMap((context) => SystemContext.reconcile(context, added._tag === "Updated" ? added.applied : {})),
|
||||
)
|
||||
expect(removed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following skills are no longer available and must not be used: effect.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
|
||||
it.effect("restates the full skill list when a description changes", () => {
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
const initialized = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
|
||||
skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })]
|
||||
expect(
|
||||
yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining(
|
||||
"The available skills have changed. This list supersedes the previous available skills list.",
|
||||
),
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
@@ -89,8 +155,8 @@ describe("SkillGuidance", () => {
|
||||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
@@ -108,8 +174,8 @@ describe("SkillGuidance", () => {
|
||||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
@@ -125,7 +191,7 @@ describe("SkillGuidance", () => {
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
expect(
|
||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).baseline,
|
||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).text,
|
||||
).toContain("<name>effect</name>")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
@@ -144,8 +210,8 @@ describe("SkillGuidance", () => {
|
||||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -27,7 +27,7 @@ const locationLayer = Layer.succeed(
|
||||
),
|
||||
),
|
||||
)
|
||||
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
|
||||
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, InstructionContext.node])
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(builtInsNode, [
|
||||
[Location.node, locationLayer],
|
||||
@@ -58,10 +58,10 @@ describe("SystemContextBuiltIns", () => {
|
||||
it.effect("loads location-scoped environment and host-local date context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
expect(initialized.baseline).toBe(
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
@@ -80,11 +80,11 @@ describe("SystemContextBuiltIns", () => {
|
||||
it.effect("reconciles the date without repeating unchanged environment context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)
|
||||
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.applied)
|
||||
|
||||
expect(refreshed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
@@ -96,20 +96,24 @@ describe("SystemContextBuiltIns", () => {
|
||||
it.effect("does not update again within the same local calendar day", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" })
|
||||
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" })
|
||||
}),
|
||||
)
|
||||
|
||||
itWithInstructions.effect("composes ambient instructions after built-in context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const builtIns = yield* SystemContextBuiltIns.Service
|
||||
const instructions = yield* InstructionContext.Service
|
||||
const context = {
|
||||
load: () => Effect.all([builtIns.load(), instructions.load()]).pipe(Effect.map(SystemContext.combine)),
|
||||
}
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* context.load())).baseline).toBe(
|
||||
expect((yield* SystemContext.initialize(yield* context.load())).text).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
|
||||
@@ -32,11 +32,11 @@ describe("SystemContext", () => {
|
||||
removed: () => "Date removed",
|
||||
})
|
||||
|
||||
expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||
expect((yield* SystemContext.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads once and initializes a baseline with a structured snapshot", () =>
|
||||
it.effect("loads once and initializes a baseline with the applied values", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.combine([
|
||||
@@ -55,8 +55,8 @@ describe("SystemContext", () => {
|
||||
])
|
||||
|
||||
expect(yield* SystemContext.initialize(context)).toEqual({
|
||||
baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
||||
snapshot: {
|
||||
text: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo" },
|
||||
},
|
||||
@@ -84,7 +84,7 @@ describe("SystemContext", () => {
|
||||
expect(yield* SystemContext.reconcile(changed, previous)).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "The date changed from 2026-06-03 to 2026-06-04.",
|
||||
snapshot: {
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-04", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
||||
},
|
||||
@@ -113,19 +113,17 @@ describe("SystemContext", () => {
|
||||
expect(yield* SystemContext.reconcile(context, {})).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Available skill: effect",
|
||||
snapshot: { "core/skills": { value: "effect" } },
|
||||
applied: { "core/skills": { value: "effect" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains admitted snapshots while a source is temporarily unavailable", () =>
|
||||
it.effect("retains the belief while a source is temporarily unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } }
|
||||
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "ReplacementReady" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -152,17 +150,29 @@ describe("SystemContext", () => {
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Instructions removed; stop applying them.",
|
||||
snapshot: {},
|
||||
applied: {},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests replacement when a source without removal text disappears", () =>
|
||||
it.effect("retains an unannounced removal silently", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toEqual({
|
||||
_tag: "Unchanged",
|
||||
})
|
||||
|
||||
// The retained belief survives alongside other updates.
|
||||
expect(
|
||||
yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }),
|
||||
).toMatchObject({
|
||||
_tag: "ReplacementReady",
|
||||
yield* SystemContext.reconcile(stringContext({ key: "core/skills", value: "effect" }), {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
}),
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "effect",
|
||||
applied: {
|
||||
"core/skills": { value: "effect" },
|
||||
"core/date": { value: "2026-06-04" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -189,17 +199,48 @@ describe("SystemContext", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests replacement when a stored value no longer decodes", () =>
|
||||
it.effect("re-announces the baseline when a stored value no longer decodes", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
}),
|
||||
).toMatchObject({ _tag: "ReplacementReady" })
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "2026-06-04",
|
||||
applied: { "core/date": { value: "2026-06-04" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces from one coherent source observation", () =>
|
||||
it.effect("renders undecodable re-announcements alongside other updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = SystemContext.combine([
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
update: (before, current) => `${before} -> ${current}`,
|
||||
}),
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
"core/date": { value: "2026-06-03" },
|
||||
"core/location": { value: 42 },
|
||||
}),
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "2026-06-03 -> 2026-06-04\n\n/repo",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
"core/location": { value: "/repo" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebaselines from one coherent source observation", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.make({
|
||||
@@ -213,52 +254,83 @@ describe("SystemContext", () => {
|
||||
update: (_previous, current) => current,
|
||||
})
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
|
||||
_tag: "ReplacementReady",
|
||||
generation: { baseline: "2026-06-04" },
|
||||
expect(yield* SystemContext.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({
|
||||
text: "2026-06-04",
|
||||
applied: { "core/date": { value: "2026-06-04" } },
|
||||
})
|
||||
expect(loads).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not render discarded updates while replacing", () =>
|
||||
it.effect("rebaselines an unavailable source from the last-applied belief", () =>
|
||||
Effect.gen(function* () {
|
||||
let updates = 0
|
||||
const context = SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-04" }),
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
update: () => {
|
||||
updates++
|
||||
return "updated"
|
||||
},
|
||||
key: "core/remote",
|
||||
value: SystemContext.unavailable,
|
||||
baseline: (value) => `Instructions: ${value}`,
|
||||
}),
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
"core/date": { value: "2026-06-03" },
|
||||
"core/location": { value: 42 },
|
||||
yield* SystemContext.rebaseline(context, {
|
||||
"core/remote": { value: "contents", removed: "Instructions removed" },
|
||||
}),
|
||||
).toMatchObject({ _tag: "ReplacementReady" })
|
||||
expect(updates).toBe(0)
|
||||
).toEqual({
|
||||
text: "2026-06-04\n\nInstructions: contents",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
"core/remote": { value: "contents", removed: "Instructions removed" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("blocks an incompatible replacement while another admitted source is unavailable", () =>
|
||||
it.effect("drops undecodable beliefs and removed sources at rebaseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
"core/remote": { value: "instructions", removed: "Instructions removed" },
|
||||
}
|
||||
const context = SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-04" }),
|
||||
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
|
||||
])
|
||||
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
// Undecodable belief cannot be restated; removed source entries self-clean.
|
||||
expect(
|
||||
yield* SystemContext.rebaseline(context, {
|
||||
"core/remote": { value: 42 },
|
||||
"core/gone": { value: "gone" },
|
||||
}),
|
||||
).toEqual({ text: "", applied: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("diffs list values by key with a changed comparator", () =>
|
||||
Effect.sync(() => {
|
||||
const previous = [
|
||||
{ name: "effect", description: "Build with Effect" },
|
||||
{ name: "debugging", description: "Diagnose bugs" },
|
||||
{ name: "retired", description: "Old" },
|
||||
]
|
||||
const current = [
|
||||
{ name: "effect", description: "Build with Effect v4" },
|
||||
{ name: "debugging", description: "Diagnose bugs" },
|
||||
{ name: "writing", description: "Write prose" },
|
||||
]
|
||||
|
||||
expect(
|
||||
SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(value) => value.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
),
|
||||
).toEqual({
|
||||
added: [{ name: "writing", description: "Write prose" }],
|
||||
removed: [{ name: "retired", description: "Old" }],
|
||||
changed: [
|
||||
{
|
||||
previous: { name: "effect", description: "Build with Effect" },
|
||||
current: { name: "effect", description: "Build with Effect v4" },
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -281,7 +353,7 @@ describe("SystemContext", () => {
|
||||
stringContext({ key: "core/date", value: "date" }),
|
||||
stringContext({ key: "core/location", value: "location" }),
|
||||
]),
|
||||
)).baseline,
|
||||
)).text,
|
||||
).toBe("date\n\nlocation")
|
||||
}),
|
||||
)
|
||||
@@ -295,13 +367,13 @@ describe("SystemContext", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires namespaced durable snapshot keys", () =>
|
||||
it.effect("requires namespaced applied keys", () =>
|
||||
Effect.sync(() => {
|
||||
const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot)
|
||||
const decodeApplied = Schema.decodeUnknownSync(SystemContext.Applied)
|
||||
|
||||
expect(Object.keys(decodeSnapshot({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
||||
expect(() => decodeSnapshot({ date: { value: "date" } })).toThrow()
|
||||
expect(() => decodeSnapshot({ "core/date": { value: "date", removed: "" } })).toThrow()
|
||||
expect(Object.keys(decodeApplied({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
||||
expect(() => decodeApplied({ date: { value: "date" } })).toThrow()
|
||||
expect(() => decodeApplied({ "core/date": { value: "date", removed: "" } })).toThrow()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Schema, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const entry = (key: string, text: string, sourceKey = key) => ({
|
||||
key: SystemContext.Key.make(key),
|
||||
load: Effect.succeed(
|
||||
SystemContext.make({
|
||||
key: SystemContext.Key.make(sourceKey),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed(text),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(SystemContextRegistry.node))
|
||||
|
||||
describe("SystemContextRegistry", () => {
|
||||
it.effect("loads empty system context when there are no entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
|
||||
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads scoped entries in stable key order", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.register(entry("test/second", "second"))
|
||||
yield* registry.register(entry("test/first", "first"))
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("re-evaluates entry producers on each load", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
let loads = 0
|
||||
yield* registry.register({
|
||||
key: SystemContext.Key.make("test/dynamic"),
|
||||
load: Effect.sync(() => {
|
||||
loads++
|
||||
return SystemContext.empty
|
||||
}),
|
||||
})
|
||||
|
||||
yield* registry.load()
|
||||
yield* registry.load()
|
||||
|
||||
expect(loads).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates entry producer failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const failure = new Error("entry failed")
|
||||
yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||
|
||||
const exit = yield* registry.load().pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(failure)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate source keys from separate entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.register(entry("test/first", "first", "test/duplicate"))
|
||||
yield* registry.register(entry("test/second", "second", "test/duplicate"))
|
||||
|
||||
const exit = yield* registry.load().pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.DuplicateKeyError)
|
||||
expect(Cause.squash(exit.cause)).toMatchObject({ key: SystemContext.Key.make("test/duplicate") })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate entry keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.register(entry("test/duplicate", "first"))
|
||||
|
||||
const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes an entry when its owning scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope))
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -148,9 +148,22 @@ const AnthropicToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
||||
])
|
||||
|
||||
const AnthropicThinking = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("disabled"),
|
||||
}),
|
||||
])
|
||||
|
||||
const AnthropicOutputConfig = Schema.Struct({
|
||||
effort: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
@@ -166,6 +179,7 @@ const AnthropicBodyFields = {
|
||||
top_k: Schema.optional(Schema.Number),
|
||||
stop_sequences: optionalArray(Schema.String),
|
||||
thinking: Schema.optional(AnthropicThinking),
|
||||
output_config: Schema.optional(AnthropicOutputConfig),
|
||||
}
|
||||
const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -492,7 +506,18 @@ const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthr
|
||||
|
||||
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
|
||||
const thinking = anthropicOptions(request)?.thinking
|
||||
if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined
|
||||
if (!ProviderShared.isRecord(thinking)) return undefined
|
||||
if (thinking.type === "adaptive") {
|
||||
const display =
|
||||
thinking.display === "summarized"
|
||||
? ("summarized" as const)
|
||||
: thinking.display === "omitted"
|
||||
? ("omitted" as const)
|
||||
: undefined
|
||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
}
|
||||
if (thinking.type === "disabled") return { type: "disabled" as const }
|
||||
if (thinking.type !== "enabled") return undefined
|
||||
const budget =
|
||||
typeof thinking.budgetTokens === "number"
|
||||
? thinking.budgetTokens
|
||||
@@ -503,6 +528,11 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
|
||||
return { type: "enabled" as const, budget_tokens: budget }
|
||||
})
|
||||
|
||||
const outputConfig = (request: LLMRequest) => {
|
||||
const effort = anthropicOptions(request)?.effort
|
||||
return typeof effort === "string" ? { effort } : undefined
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
@@ -549,6 +579,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: yield* lowerThinking(request),
|
||||
output_config: outputConfig(request),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -168,8 +168,6 @@ interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
}
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
@@ -333,8 +331,6 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
|
||||
const store = OpenAIOptions.store(request)
|
||||
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
|
||||
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
|
||||
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
|
||||
return {
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||
|
||||
@@ -457,8 +457,6 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
|
||||
const store = OpenAIOptions.store(request)
|
||||
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
|
||||
const effort = OpenAIOptions.reasoningEffort(request)
|
||||
if (effort && !OpenAIOptions.isReasoningEffort(effort))
|
||||
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
|
||||
const summary = OpenAIOptions.reasoningSummary(request)
|
||||
const include = OpenAIOptions.include(request)
|
||||
const verbosity = OpenAIOptions.textVerbosity(request)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Schema } from "effect"
|
||||
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
|
||||
import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema"
|
||||
import { ReasoningEfforts, TextVerbosity } from "../../schema"
|
||||
|
||||
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
|
||||
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
|
||||
)
|
||||
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
||||
export const OpenAIReasoningEfforts = ReasoningEfforts
|
||||
export type OpenAIReasoningEffort = string
|
||||
|
||||
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
@@ -23,22 +21,16 @@ export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number
|
||||
export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
|
||||
|
||||
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
||||
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
||||
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
|
||||
|
||||
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
||||
export const OpenAIReasoningEffort = Schema.String
|
||||
export const OpenAITextVerbosity = TextVerbosity
|
||||
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
||||
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
|
||||
|
||||
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
||||
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
||||
|
||||
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
|
||||
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
|
||||
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
|
||||
|
||||
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
|
||||
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
||||
@@ -50,9 +42,9 @@ export const store = (request: LLMRequest): boolean | undefined => {
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
|
||||
export const reasoningEffort = (request: LLMRequest): string | undefined => {
|
||||
const value = options(request)?.reasoningEffort
|
||||
return isAnyReasoningEffort(value) ? value : undefined
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ToolCallID = Schema.String
|
||||
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
|
||||
|
||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
||||
export const ReasoningEffort = Schema.String
|
||||
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
||||
|
||||
export const TextVerbosity = Schema.Literals(["low", "medium", "high"])
|
||||
|
||||
@@ -57,6 +57,23 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers adaptive thinking settings with effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.updateRequest(request, {
|
||||
providerOptions: {
|
||||
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "low" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
|
||||
@@ -98,12 +98,26 @@ describe("OpenAI Chat route", () => {
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
|
||||
prompt: "think",
|
||||
providerOptions: { openai: { reasoningEffort: "low" } },
|
||||
providerOptions: { openai: { reasoningEffort: "max" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(false)
|
||||
expect(prepared.body.reasoning_effort).toBe("low")
|
||||
expect(prepared.body.reasoning_effort).toBe("max")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "think",
|
||||
providerOptions: { openai: { reasoningEffort: "experimental" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.reasoning_effort).toBe("experimental")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +69,16 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes through custom OpenAI reasoning effort strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
# Simulation Implementation Phases
|
||||
|
||||
Status: implementation plan for `specs/simulation.md`.
|
||||
|
||||
The full simulation architecture is intentionally broad. This document breaks it into phases that can be implemented and reviewed incrementally.
|
||||
|
||||
## Phase 1: Control Surface And Observability
|
||||
|
||||
Goal: start the normal app in simulation mode and inspect/drive the TUI through an external WebSocket driver.
|
||||
|
||||
This phase proves the core shape without swapping every foundational layer yet.
|
||||
|
||||
Implementation checklist:
|
||||
|
||||
- [x] Add `OPENCODE_SIMULATION=1` activation in V1/full-TUI startup.
|
||||
- [x] Add simulation trace service with in-memory append-only records.
|
||||
- [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions.
|
||||
- [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click.
|
||||
- [x] Add reusable JSON-RPC WebSocket server on `127.0.0.1:40900+`.
|
||||
- [x] Expose `ui.state`, `ui.action`, `ui.render`.
|
||||
- [x] Expose `trace.list`, `trace.clear`, `trace.export`.
|
||||
- [x] Wire visible V1/full-TUI renderer path through the same action protocol.
|
||||
- [ ] Verify a local driver can inspect state and execute a real TUI input.
|
||||
|
||||
Scope:
|
||||
|
||||
- Add `OPENCODE_SIMULATION=1` activation.
|
||||
- Start a TUI-owned JSON-RPC WebSocket server on `127.0.0.1:40900+`.
|
||||
- Expose `ui.state`, `ui.action`, `ui.render`.
|
||||
- Use the old simulation action model: type text, press keys, press enter, arrows, focus, click.
|
||||
- Support fake OpenTUI renderer and visible renderer through the same action protocol.
|
||||
- Add in-memory append-only trace with `trace.list`, `trace.clear`, `trace.export`.
|
||||
- Record UI observations, generated actions, executed actions, errors, and render/stabilization events.
|
||||
|
||||
Done when:
|
||||
|
||||
- `OPENCODE_SIMULATION=1 bun run dev` starts the normal app.
|
||||
- A local driver can connect to the WebSocket.
|
||||
- The driver can inspect current screen/elements/actions.
|
||||
- The driver can execute real TUI inputs.
|
||||
- The trace shows observations and actions.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Backend layer replacement.
|
||||
- Model-based runner.
|
||||
- Generated plugin config.
|
||||
- Deterministic replay tests.
|
||||
|
||||
## Phase 2: Foundational Simulation Layers
|
||||
|
||||
Goal: make the app safe and controlled by swapping the lowest layers, not app logic.
|
||||
|
||||
Scope:
|
||||
|
||||
- Wire simulation replacements through `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)`.
|
||||
- Add temp-directory-backed filesystem isolation.
|
||||
- Route config/data/state/cache/temp paths into the simulation temp root.
|
||||
- Deny host filesystem escapes loudly.
|
||||
- Add simulated network registry and deny unknown external network by default.
|
||||
- Add scriptable LLM boundary.
|
||||
- Add simulated process registry:
|
||||
- shell through `just-bash` against the simulated filesystem.
|
||||
- minimal fake `git` support for discovery/status paths.
|
||||
- deny unsupported process spawns.
|
||||
- Add simulation-gated backend control routes, proxied only through the frontend WebSocket.
|
||||
- Expose backend methods through the frontend server: filesystem seed/write, network register, LLM enqueue, backend snapshot.
|
||||
- Trace filesystem, network, LLM, process, and backend control activity.
|
||||
|
||||
Done when:
|
||||
|
||||
- Unknown network fails with a simulation error.
|
||||
- Host filesystem escape fails with a simulation error.
|
||||
- A driver can seed a project filesystem.
|
||||
- A driver can enqueue an LLM script and submit a prompt through the TUI.
|
||||
- The real session/tool path consumes the scripted LLM behavior.
|
||||
- Shell commands use `just-bash`; unsupported process spawns fail.
|
||||
- Trace contains backend activity and snapshots.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Model-based generation.
|
||||
- Generated plugin config state.
|
||||
- Shrinking.
|
||||
|
||||
## Phase 3: Generated Config And Model-Based Runner
|
||||
|
||||
Goal: explore different app states using generated commands and plugin-provided config state.
|
||||
|
||||
Scope:
|
||||
|
||||
- Add generated simulation plugins as the primary config-state generation mechanism.
|
||||
- Support generated plugin domains for:
|
||||
- agents and defaults.
|
||||
- provider/model availability.
|
||||
- tool definitions and scripted tool behavior.
|
||||
- MCP-like capabilities or endpoints.
|
||||
- permission policies.
|
||||
- instructions/system-context-like inputs where supported.
|
||||
- workspace/project adapters where supported.
|
||||
- Add runner commands to generate, enable, disable, and inspect generated plugin state.
|
||||
- Build a custom external model-based runner, not `fast-check` yet.
|
||||
- Runner command shape: precondition, execute, model update, postcondition.
|
||||
- Runner model tracks only high-level observational state: screen category, prompt availability, sessions, files, queued LLM scripts, generated plugins, backend status, idle expectation.
|
||||
- Generate valid command sequences from model state and current `ui.state.actions`.
|
||||
- Record seed, command distribution, precondition rejections, generated plugin/config domain coverage, UI action coverage, and backend event coverage.
|
||||
|
||||
Done when:
|
||||
|
||||
- A seeded runner can generate a short valid exploration.
|
||||
- The runner can generate plugin-provided config state without generating large arbitrary config files.
|
||||
- The app loads and observes generated plugin state through normal plugin/config paths.
|
||||
- The runner can type and submit prompts through the TUI using generated actions.
|
||||
- Basic properties run after commands: no crash, no unknown network, no host FS escape, coherent stabilized state.
|
||||
- Trace export includes enough state to replay the generated run later.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Shrinking.
|
||||
- Coverage-guided mutation corpus.
|
||||
- Differential testing.
|
||||
- CI randomized runs.
|
||||
|
||||
## Phase 4: Replay, Promotion, And Campaigns
|
||||
|
||||
Goal: turn exploratory simulation into durable tests and prepare for larger campaigns.
|
||||
|
||||
Scope:
|
||||
|
||||
- Add replay from exported trace.
|
||||
- Add deterministic replay test generation from successful or failing traces.
|
||||
- Add stronger trace schema validation.
|
||||
- Add property families beyond no-crash:
|
||||
- durable prompt admission is not lost.
|
||||
- no duplicated visible message IDs.
|
||||
- no orphan tool results.
|
||||
- queue/steer semantics hold at stabilization boundaries.
|
||||
- interrupt/resume does not duplicate promoted inputs.
|
||||
- Add corpus storage for interesting traces.
|
||||
- Add simple coverage/novelty scoring over UI states, backend event types, tool outcomes, generated config domains, and errors.
|
||||
- Add long-running campaign mode outside normal CI.
|
||||
|
||||
Done when:
|
||||
|
||||
- A trace from Phase 3 can be replayed deterministically.
|
||||
- A trace can be promoted to a normal test fixture.
|
||||
- Campaign runs can collect interesting traces without committing randomized tests to CI.
|
||||
- Failures produce a compact reproduction command and trace export.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Full shrinking.
|
||||
- Deterministic scheduler/clock control.
|
||||
- Parallel campaigns.
|
||||
- Differential testing across app versions.
|
||||
|
||||
## Later Work
|
||||
|
||||
- Shrinking failed traces.
|
||||
- Coverage-guided mutation of structured traces.
|
||||
- `fast-check` integration if the custom runner becomes too limited.
|
||||
- Differential testing across versions, renderers, storage modes, or scheduler policies.
|
||||
- Deterministic clock/random/scheduler control.
|
||||
- Parallel isolated workers.
|
||||
- Model-generated properties with validity/soundness/coverage scoring.
|
||||
@@ -1,270 +0,0 @@
|
||||
# Architecture Synthesis For Opencode Simulation
|
||||
|
||||
## Core Direction
|
||||
|
||||
The simulation system should be a real-app, TUI-driven, stateful model-based testing harness. It should not be a custom app command, a forked backend, or a broad reimplementation of opencode services.
|
||||
|
||||
The production app should run normally with one required flag:
|
||||
|
||||
```sh
|
||||
OPENCODE_SIMULATION=1 bun run dev
|
||||
```
|
||||
|
||||
That flag enables narrow foundational layer replacements and starts a frontend-owned simulation WebSocket server. The backend server remains the normal server; simulation-only backend controls are gated behind the flag and are only reached through the frontend control server.
|
||||
|
||||
## Control Surface
|
||||
|
||||
- Frontend TUI process owns the external control server.
|
||||
- Protocol: JSON-RPC 2.0 over WebSocket.
|
||||
- Bind loopback only.
|
||||
- Start at port `40900`, scan upward if occupied, and log/display the actual URL.
|
||||
- External drivers never talk directly to backend simulation routes.
|
||||
- The frontend proxies backend simulation control over the same transport the TUI already uses.
|
||||
|
||||
Initial JSON-RPC method families:
|
||||
|
||||
- `ui.state`: screen, elements, focus, generated executable actions.
|
||||
- `ui.action`: execute real user-level inputs through OpenTUI APIs.
|
||||
- `ui.render`: force/wait for render and return state.
|
||||
- `backend.filesystem.seed` / `backend.filesystem.write`.
|
||||
- `backend.network.register`.
|
||||
- `backend.llm.enqueue`.
|
||||
- `backend.snapshot`.
|
||||
- `trace.list` / `trace.clear` / `trace.export`.
|
||||
- `run.stabilize`: wait for TUI/backend quiescence and return observations.
|
||||
|
||||
## Action Model
|
||||
|
||||
Actions must stay at real user-input level. The old branch's action vocabulary is the right starting point:
|
||||
|
||||
```ts
|
||||
type UIAction =
|
||||
| { type: "typeText"; text: string }
|
||||
| { type: "pressKey"; key: string; modifiers?: KeyModifiers }
|
||||
| { type: "pressEnter" }
|
||||
| { type: "pressArrow"; direction: "up" | "down" | "left" | "right" }
|
||||
| { type: "focus"; target: number }
|
||||
| { type: "click"; target: number; x: number; y: number }
|
||||
```
|
||||
|
||||
`ui.state` should return both:
|
||||
|
||||
- `elements`: focusable/clickable/editor renderables.
|
||||
- `actions`: generated executable actions derived from those elements.
|
||||
|
||||
Fake OpenTUI renderer and visible terminal renderer should both adapt to this same action protocol.
|
||||
|
||||
## Foundational Layer Replacement
|
||||
|
||||
Simulation should plug into current `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)` replacement seams.
|
||||
|
||||
Acceptable core boundaries to swap:
|
||||
|
||||
- `FileSystem.FileSystem` / `FSUtil.Service` where needed.
|
||||
- `HttpClient.HttpClient`.
|
||||
- `RequestExecutor` / `LLMClient` or equivalent lowest LLM boundary.
|
||||
- Process spawner.
|
||||
- Global paths / env / temp paths.
|
||||
- Clock/random later, if needed.
|
||||
- Database path or in-memory DB when simulation requires isolation.
|
||||
|
||||
Avoid replacing application services unless a core boundary is impossible:
|
||||
|
||||
- Do not replace `SessionPrompt`, `SessionProcessor`, `ToolRegistry`, route trees, or app-level provider abstractions as the default approach.
|
||||
- Do not add a custom `simulate` command as the primary execution path.
|
||||
- Do not implement a parallel fake app.
|
||||
|
||||
## Trace Model
|
||||
|
||||
The frontend simulation server owns an in-memory append-only trace log by default.
|
||||
|
||||
Trace records should include:
|
||||
|
||||
- Run metadata: app version, seed, renderer mode, simulation URL.
|
||||
- Initial world setup: filesystem, config, environment, backend state.
|
||||
- UI observations: screen, elements, generated actions, focused item.
|
||||
- UI actions: command sent, concrete OpenTUI action, timestamp/order.
|
||||
- Backend events: session events, provider requests, tool calls/results, permission decisions, status transitions.
|
||||
- LLM scripts consumed and provider stream chunks.
|
||||
- Filesystem and network operations/diffs.
|
||||
- Stabilization boundaries and idle checks.
|
||||
- Property checks and failures.
|
||||
- Driver/model prompts and decisions when a model is driving exploration.
|
||||
|
||||
The trace must support:
|
||||
|
||||
- `trace.list`: query recent records.
|
||||
- `trace.clear`: reset in-memory log.
|
||||
- `trace.export`: produce JSON suitable for replay or test generation.
|
||||
|
||||
File writing is not required initially, but the trace schema should be JSONL-friendly.
|
||||
|
||||
## Model-Based Core
|
||||
|
||||
The architecture should distinguish five concepts:
|
||||
|
||||
```ts
|
||||
type Scenario = InitialWorld & { actions: readonly Action[] }
|
||||
type Action = UIAction | BackendControlAction | DriverAction
|
||||
type Observation = UIObservation | BackendObservation | TraceObservation
|
||||
type Model = abstract state machine over observable domains
|
||||
type Property = (model, observations) => pass | fail | inconclusive
|
||||
```
|
||||
|
||||
The model should be an executable specification, not a copy of production implementation.
|
||||
|
||||
Good model properties:
|
||||
|
||||
- Abstract.
|
||||
- Observable.
|
||||
- Finite nondeterminism.
|
||||
- Explicit preconditions and postconditions.
|
||||
- Valid transition relation.
|
||||
- Allows multiple valid outcomes where opencode behavior is intentionally nondeterministic.
|
||||
|
||||
Bad model properties:
|
||||
|
||||
- Reimplements `SessionRunner` or provider streaming.
|
||||
- Mirrors database internals exactly.
|
||||
- Uses the same branch logic as production code.
|
||||
- Requires complete knowledge of implementation scheduling.
|
||||
|
||||
## Quiescence
|
||||
|
||||
Quiescence is a first-class command, not a timeout hidden in tests.
|
||||
|
||||
`run.stabilize` should check, as far as possible:
|
||||
|
||||
- TUI render is idle.
|
||||
- No active runner for the target session.
|
||||
- No eligible durable input remains unprocessed unless intentionally queued.
|
||||
- No running tool calls remain.
|
||||
- Session status is stable.
|
||||
- Projected transcript is stable across repeated reads.
|
||||
- Backend event stream has no immediate pending event burst.
|
||||
|
||||
If quiescence cannot be proved, the command should return structured uncertainty instead of silently sleeping.
|
||||
|
||||
## Properties And Oracles
|
||||
|
||||
Initial built-in properties should be simple and high-signal:
|
||||
|
||||
- No frontend/backend crash.
|
||||
- No unknown external network unless explicitly registered.
|
||||
- No host filesystem escape.
|
||||
- No duplicated visible message IDs.
|
||||
- No orphan tool results.
|
||||
- Durable prompt admission is not lost.
|
||||
- Exact prompt retry reconciles or fails according to identity rules.
|
||||
- Queue/steer promotion preserves documented delivery semantics.
|
||||
- Interrupt/resume does not duplicate promoted inputs.
|
||||
- Stabilized sessions have coherent status and transcript.
|
||||
|
||||
Use multiple oracle styles:
|
||||
|
||||
- Invariant checks after every step.
|
||||
- Model/refinement checks against allowed abstract outcomes.
|
||||
- Metamorphic checks across related runs.
|
||||
- Differential checks across app versions, renderers, or storage modes.
|
||||
|
||||
## Generation Strategy
|
||||
|
||||
Start deterministic and structured.
|
||||
|
||||
Inputs to generate:
|
||||
|
||||
- Workspace filesystem shapes.
|
||||
- Config variants.
|
||||
- Auth/provider/model states.
|
||||
- Session histories.
|
||||
- Prompt text and delivery modes.
|
||||
- LLM scripts with text, tool calls, errors, chunk boundaries.
|
||||
- Tool outcomes and permission decisions.
|
||||
- UI action sequences chosen from `ui.state.actions`.
|
||||
- Interrupt/restart/crash points.
|
||||
|
||||
Use fast-check later for the external runner:
|
||||
|
||||
- `asyncProperty` for E2E simulation.
|
||||
- `commands` for model-based command sequences.
|
||||
- Seed/path replay.
|
||||
- Shrinking action sequences.
|
||||
|
||||
Do not run huge property suites in CI by default. Use simulation to discover/minimize traces, then generate normal deterministic tests for CI.
|
||||
|
||||
## Coverage Guidance
|
||||
|
||||
The simulation server and runner should collect effectiveness signals from day one:
|
||||
|
||||
- UI element/action coverage.
|
||||
- App route/screen coverage.
|
||||
- Backend event type coverage.
|
||||
- Session state transition coverage.
|
||||
- Tool/permission outcome coverage.
|
||||
- Network route coverage.
|
||||
- Filesystem operation coverage.
|
||||
- Error/retry/recovery coverage.
|
||||
- Discarded/precondition-failed cases.
|
||||
|
||||
Later, preserve interesting traces as corpus seeds and mutate them structurally.
|
||||
|
||||
## Shrinking And Test Generation
|
||||
|
||||
Shrinking should operate on semantic trace structures:
|
||||
|
||||
- Remove actions.
|
||||
- Reduce prompt text.
|
||||
- Reduce filesystem size.
|
||||
- Remove unrelated files/config fields.
|
||||
- Simplify LLM scripts.
|
||||
- Remove chunks while preserving tool-call validity.
|
||||
- Move or remove interrupt/restart points.
|
||||
- Reduce UI navigation before the failing action.
|
||||
|
||||
Generated normal tests should include:
|
||||
|
||||
- Minimal initial world.
|
||||
- Explicit UI action sequence.
|
||||
- Explicit LLM/tool/network scripts.
|
||||
- Stabilization calls.
|
||||
- Deterministic assertions over semantic observations.
|
||||
|
||||
## LLM/Agent Driver Feedback
|
||||
|
||||
The same WebSocket observations should support model-driven exploration.
|
||||
|
||||
Expose enough semantic context for a model to reason:
|
||||
|
||||
- Screen text.
|
||||
- TUI elements with roles, labels, selectors, positions, capabilities.
|
||||
- Available actions.
|
||||
- Backend snapshot summaries.
|
||||
- Recent trace records.
|
||||
- Filesystem summary/diff.
|
||||
- Network log.
|
||||
- Session/message/tool summary.
|
||||
- Property failures and uncertainty.
|
||||
|
||||
Generated properties from models should follow a lifecycle:
|
||||
|
||||
- Proposed from evidence.
|
||||
- Translated to executable checks.
|
||||
- Validity checked.
|
||||
- Soundness checked on normal behavior.
|
||||
- Coverage/adversarial checked where possible.
|
||||
- Accepted, refined, or rejected with reason.
|
||||
|
||||
## First Implementation Milestone
|
||||
|
||||
Minimal useful milestone:
|
||||
|
||||
1. `OPENCODE_SIMULATION=1` activates simulation replacements and frontend control server.
|
||||
2. Frontend starts JSON-RPC WebSocket on `127.0.0.1:40900+`.
|
||||
3. `ui.state`, `ui.action`, `ui.render`, `trace.list`, `trace.clear`, `trace.export` work.
|
||||
4. Fake and visible OpenTUI renderers share the same action protocol.
|
||||
5. Backend simulation controls are proxied through frontend WebSocket.
|
||||
6. Backend has gated control routes only when simulation flag is enabled.
|
||||
7. In-memory trace records UI actions, observations, backend control calls, and backend snapshots.
|
||||
8. Foundational replacements cover at least LLM and network denial/registration.
|
||||
9. A simple driver can seed LLM response, type a prompt through the TUI, press enter, stabilize, and assert no crash plus response visible.
|
||||
10. The resulting trace can be exported as deterministic JSON for later replay/test generation.
|
||||
@@ -1,290 +0,0 @@
|
||||
# Paper Analyses For Simulation Architecture
|
||||
|
||||
This document extracts implementation-relevant lessons from the local paper corpus. The focus is opencode's proposed simulation system: real TUI-driven execution, narrow foundational layer replacement, WebSocket control, trace recording, model-based/property-based exploration, and generated deterministic tests.
|
||||
|
||||
## 2016: Mysteries of Dropbox
|
||||
|
||||
**Main idea:** stateful PBT can find bugs in real, black-box, nondeterministic distributed systems by generating action sequences, recording observations, and checking whether the observation trace has some valid explanation under a small model.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Generate commands separately from observed effects.
|
||||
- Model hidden nondeterministic events explicitly, even when the SUT does not expose them.
|
||||
- Accept traces if there exists a sequence of hidden events that makes observations valid.
|
||||
- Maintain possible model states, not a single expected state.
|
||||
- Make quiescence explicit with a `STABILIZE` command.
|
||||
- Re-run flaky failures during shrinking when nondeterminism cannot be fully controlled.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Quiescence detection can lie.
|
||||
- A model that is too permissive can explain away real bugs.
|
||||
- Timing-dependent failures need either scheduler control or repeated validation.
|
||||
- Raw happens-before modeling can become elegant but impractical.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Do not compare raw transcripts only. Compare observed behavior against allowed abstract outcomes.
|
||||
- Represent hidden runtime transitions like prompt promotion, session wake, provider turn continuation, tool completion, retries, interrupt delivery, and event projection.
|
||||
- Add a `drainUntilIdle` or `stabilize` simulation command with strict checks.
|
||||
- Record all generated commands, UI actions, backend events, provider scripts, tool results, and snapshots in an append-only trace.
|
||||
- Shrinking should preserve semantic validity and revalidate nondeterministic failures.
|
||||
|
||||
## 2019: Coverage Guided, Property Based Testing
|
||||
|
||||
**Main idea:** plain random generators often fail when valid inputs have sparse semantic preconditions. Coverage-guided PBT keeps interesting inputs and mutates structured values to explore deeper states.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Maintain a corpus of inputs that improve coverage.
|
||||
- Mutate typed structures rather than raw bytes.
|
||||
- Keep both successful seeds and promising discarded seeds.
|
||||
- Fall back to random generation when mutation stalls.
|
||||
- Use coverage and progress counters, not only binary edge coverage.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Instrumenting irrelevant framework code hurts performance.
|
||||
- Generic mutators can explode in search space.
|
||||
- Expert handwritten generators still outperform generic mutation but are expensive.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Preserve interesting `Scenario` and `Trace` seeds.
|
||||
- Add structured mutators for prompts, tool calls, provider chunks, permission decisions, filesystems, config, interrupt timing, crash/restart points, and scheduler actions.
|
||||
- Track semantic novelty: event types, session states, tool outcomes, permission branches, replay/recovery paths, and UI routes.
|
||||
- Avoid byte fuzzing as the core; use it only inside fields that are naturally bytes/text.
|
||||
|
||||
## 2021: Model-Based Testing In Practice
|
||||
|
||||
**Main idea:** MBT works in industrial E2E systems when models are pragmatic, visible, and integrated into normal automation. Graph-like models are useful because actions and assertions are explicit and coverage is understandable.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Model nodes as states/checkpoints and edges as actions.
|
||||
- Split large systems into small composable models.
|
||||
- Use traversal strategies, weights, and stop conditions.
|
||||
- Report paths, coverage, and model transitions.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Heavy formal models reduce adoption.
|
||||
- Auto-inferred models can be noisy and costly to clean up.
|
||||
- Coverage metrics must be live and inspectable.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Start with a small simulation DSL, not a complete formal model.
|
||||
- Model domains separately: session lifecycle, prompt admission, queue/steer, tool execution, permissions, interrupts, compaction, crash/restart.
|
||||
- Track command coverage, transition coverage, property coverage, and failure-mode coverage.
|
||||
- Keep generated failure output readable: model path, user inputs, app observations, violated invariant.
|
||||
|
||||
## 2022: Property-Based Testing For Metamorphic Testing
|
||||
|
||||
**Main idea:** metamorphic testing helps when exact expected outputs are unavailable. It checks relations between multiple executions or transformed inputs.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Generate source cases, derive follow-up cases, and compare related outputs.
|
||||
- Use generators and shrinkers that preserve relation validity.
|
||||
- Combine multiple metamorphic relations.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Weak metamorphic relations miss real faults.
|
||||
- Naive shrinkers can break validity.
|
||||
- Reimplementing production logic in the oracle makes the test useless.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Use metamorphic relations for nondeterministic model behavior.
|
||||
- Examples:
|
||||
- Same prompt ID and same delivery mode should reconcile exactly on retry.
|
||||
- Queueing independent prompts should preserve durable admission order.
|
||||
- Interrupt/resume should not duplicate promoted user messages or orphan tool results.
|
||||
- Crash after durable admission should not invent provider work unless recovery explicitly permits it.
|
||||
- Fake renderer and visible renderer should agree on semantic action results.
|
||||
- Two app versions should satisfy the same semantic invariants for the same trace.
|
||||
|
||||
## 2022: Climbing The Stairway To Verification
|
||||
|
||||
**Main idea:** PBT becomes stronger when it mirrors a refinement/specification structure. The test asks whether implementation behavior refines an executable abstract model.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Generate one canonical test case and project it into abstract and concrete worlds.
|
||||
- Compare implementation output to a finite set of model-allowed outcomes.
|
||||
- Keep models abstract and observable.
|
||||
- Use executable specs as cheaper, incremental versions of formal proofs.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- The model can become a second implementation.
|
||||
- Strong preconditions plus random generation cause excessive discarded tests.
|
||||
- Overly abstract nondeterminism can explode.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Build simulation around `scenario -> model outcomes -> real app run -> relation check`.
|
||||
- The model should represent visible session, message, tool, provider, permission, event, status, and filesystem effects.
|
||||
- The model must not reimplement `SessionRunner`, provider streaming, tool registry, or Effect scheduling.
|
||||
- Generate concrete scenarios and derive abstract model inputs from them.
|
||||
|
||||
## 2023: QuickerCheck
|
||||
|
||||
**Main idea:** PBT and shrinking can be parallelized, especially for expensive properties, if workers have isolated state and reproducible seeds.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Give each worker its own PRNG seed and size schedule.
|
||||
- Stop all workers after the first counterexample.
|
||||
- Run cleanup/finalizers for interrupted effectful properties.
|
||||
- Use greedy parallel shrinking when deterministic minimality is less important than speed.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Shared filesystem/global state breaks parallel PBT.
|
||||
- Cancellation can leave processes, files, sockets, or locks behind.
|
||||
- Parallel shrinking can be slower for cheap properties.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Design simulation workers as isolated from the start: workspace, DB, ports, fake providers, random seeds, trace buffers.
|
||||
- Use `(campaignSeed, workerID, caseIndex)` for reproducibility.
|
||||
- Separate fast local runs from long parallel campaigns.
|
||||
- Add cleanup boundaries for every case.
|
||||
|
||||
## 2024: Can Large Language Models Write Good Property-Based Tests?
|
||||
|
||||
**Main idea:** LLMs can synthesize useful PBTs, but generated tests must be validated for validity, soundness, and property coverage. Two-stage prompting outperforms monolithic generation.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- First extract properties, then generate tests for one property at a time.
|
||||
- Classify failures as invalid test, unsound property, weak property, or real bug.
|
||||
- Use mutation/property coverage to check whether a property actually detects violations.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Passing generated tests can be weak.
|
||||
- LLMs overgeneralize documentation and miss implicit preconditions.
|
||||
- Mutation coverage can be noisy if mutants are invalid or equivalent.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Treat model-generated simulation properties as candidates.
|
||||
- Store property lifecycle: proposed, executable, validity-checked, soundness-checked, coverage-scored, accepted, rejected.
|
||||
- Generate small focused properties, not one huge “test opencode” property.
|
||||
- Expose enough observations for a model to validate its own property assumptions.
|
||||
|
||||
## 2024: Property-Based Testing In Practice
|
||||
|
||||
**Main idea:** experienced developers use PBT in a small number of high-leverage patterns. The hardest parts are writing useful properties, writing generators, shrinking, and knowing whether passing tests mean anything.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- High-leverage patterns include differential testing, model-based tests, round trips, catastrophic failure properties, and invariants.
|
||||
- Developers validate PBT effectiveness through mutation testing, example inspection, code coverage, property coverage, and supplementary example tests.
|
||||
- Passing tests need inspectable generated examples and distribution feedback.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Derived generators can create false confidence.
|
||||
- Shrinkers can violate invariants.
|
||||
- Slow PBTs get removed.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Provide built-in property families instead of requiring every contributor to invent properties.
|
||||
- Show generator stats: action distribution, trace length, discarded cases, transition coverage, example traces.
|
||||
- Make failure output a concise, reviewable artifact.
|
||||
- Support “promote minimized trace to normal test.”
|
||||
|
||||
## 2026: Agentic PBT
|
||||
|
||||
**Main idea:** an agentic loop can generate better PBTs than one-shot prompting by inspecting code/docs, proposing evidence-backed properties, running tests, triaging failures, refining false alarms, and reporting only reproducible bugs.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Use a structured loop: inspect, propose, execute, triage, refine, report.
|
||||
- Prefer high-value property patterns: invariants, round trips, inverse operations, multiple implementations, laws, confluence, metamorphic relations, and no-crash parser entrypoints.
|
||||
- Keep an evidence chain for every property.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Intent ambiguity is the main false-positive source.
|
||||
- Internal helpers often have implicit preconditions.
|
||||
- Extreme generated inputs can be unrealistic.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- The simulation system should be friendly to model-driven exploration, not just batch tests.
|
||||
- Store prompts, observations, selected actions, available actions, traces, refinements, and final classifications.
|
||||
- Add a triage workflow before surfacing model-generated failures as bugs.
|
||||
|
||||
## 2026: Evolution Of Python Tests Into PBT
|
||||
|
||||
**Main idea:** existing example and parameterized tests often evolve naturally into PBTs. Generated deterministic tests and PBTs should be connected, not treated as separate worlds.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Convert constants/parameter tables into generators.
|
||||
- Keep explicit examples for known edge cases.
|
||||
- Adjust generators/settings over time as tests mature.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Coverage can be inflated by harness/generator code.
|
||||
- PBTs can fail early and cover fewer later assertions.
|
||||
- Slow PBTs are removed.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Use existing tests as simulation corpus seeds.
|
||||
- Convert minimized simulation traces into normal deterministic tests.
|
||||
- Keep SUT coverage separate from simulation harness coverage.
|
||||
- Store generated regressions as explicit fixtures.
|
||||
|
||||
## 2026: Natural Language To Executable Properties For Mobile Apps
|
||||
|
||||
**Main idea:** natural-language app properties can become executable UI properties if the system first performs semantic grounding of UI elements.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Decompose property synthesis into UI semantic grounding and executable property synthesis.
|
||||
- Represent properties as precondition, interaction scenario, postcondition.
|
||||
- Enrich each widget with text, ID, type, semantic label, functionality, screenshot/crop, and provenance.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Similar widgets cause grounding errors.
|
||||
- Free-form natural language is less reliable than structured Given/When/Then descriptions.
|
||||
- Incorrect preconditions/postconditions are more common than incorrect interactions.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Expose semantic TUI state, not just screen text and coordinates.
|
||||
- Elements/actions should have stable IDs, roles, labels, capabilities, focus/click/edit metadata, visibility, and provenance.
|
||||
- Generated properties should use a precondition/action/postcondition structure.
|
||||
|
||||
## 2026: PropGen Mobile App Testing
|
||||
|
||||
**Main idea:** properties can be generated from runtime behavioral evidence. The loop is exploration, evidence collection, property synthesis, executable translation, testing, feedback, and refinement.
|
||||
|
||||
Useful techniques:
|
||||
|
||||
- Record condition-action-outcome traces.
|
||||
- Use functionality-guided exploration plus random exploration fallback.
|
||||
- Refine imprecise properties by classifying whether the problem is precondition, interaction, or postcondition.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- Single traces cause overfitting.
|
||||
- Generated properties can assert incidental UI details.
|
||||
- Refinement can overfit unless anchored to original evidence.
|
||||
|
||||
Implications for opencode:
|
||||
|
||||
- Record rich traces with before-state, action intent, concrete action, model/provider/tool effects, after-state, state diff, and outcome label.
|
||||
- Let models derive properties from observed behavior, then validate and refine them.
|
||||
- Use both model-guided goals and stochastic action exploration.
|
||||
@@ -1 +0,0 @@
|
||||
*.pdf binary
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user