Compare commits

..

21 Commits

Author SHA1 Message Date
Kit Langton 81ef54ef29 refactor(core): migrate built-in tools to internal plugins 2026-07-02 10:59:39 -04:00
Kit Langton 016d296f7c refactor(core): migrate glob tool to internal plugin (#34946) 2026-07-02 10:39:04 -04:00
Kit Langton dbaa53329c refactor(core): mechanism-neutral context entry rendering (#34945) 2026-07-02 10:16:24 -04:00
Kit Langton 7843f8fb38 feat(core): API-managed per-session context entries (#34941) 2026-07-02 10:05:47 -04:00
Kit Langton 4045041554 refactor(core): simplify system context into a belief model (#34917) 2026-07-02 09:32:35 -04:00
Shoubhit Dash 7ec7413fdb fix(tui): suppress transient reconnect overlay flashes (#34924) 2026-07-02 18:54:51 +05:30
Simon Klee f016392368 feat(mini): migrate mini to v2 (#34895)
feat(run): migrate non-interactive prompts to V2
feat(run): route mini prompts through V2
fix(run): use current session contracts
fix(run): fix V2 prompt turns
feat(cli): add mini subcommand
feat(run): use settled execution events
fix(run): handle remote prompt file attachments
feat(run): send prompt files as attachments
feat(run): use current APIs for run state
fix(run): adopt app-node runtime deps
feat(run): track subagent sessions
feat(run): move catalogs and default model onto current APIs
2026-07-02 12:06:49 +02:00
Aiden Cline 140224b0fc fix(core): derive models.dev reasoning variants (#34726)
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
2026-07-02 00:23:46 -05:00
opencode-agent[bot] cfd35c9354 fix(tui): include variant in model switch notice (#34856)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-02 00:17:18 -05:00
Kit Langton 674d08f9be fix(core): route ChatGPT OAuth to the codex backend (#34843) 2026-07-02 00:15:34 -04:00
opencode-agent[bot] 02f012f5d1 fix(tui): clear onboarding after provider connect (#34819)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-01 21:10:24 -05:00
Dax Raad bea6e1499d feat(core): discover instructions on directory reads 2026-07-01 21:11:22 -04:00
Dax Raad 50a1fe49bc fix(core): relativize instruction description to project root 2026-07-01 21:03:46 -04:00
Dax Raad 6380919fda fix(core): use relative paths in instruction load description 2026-07-01 21:02:47 -04:00
Dax Raad d972aa9d83 feat(core): tool execute hooks, session instructions, synthetic endpoint
- V2 tool execute hooks: add `ctx.tool.execute.before/after` plugin API,
  core ToolHooks service, and registry wiring so hosted/local tool calls
  run registered before/after hooks; provider-executed calls are excluded.
- Session synthetic endpoint: add `POST /api/session/:sessionID/synthetic`
  with text/description/metadata plus regenerated Promise/Effect/JS client
  surfaces.
- SessionInstructions service: read tool discovers nearby AGENTS.md walking
  up to the Location root (exclusive) and injects them as durable synthetic
  instructions, with lazy history dedup of prior claims.
- to-llm-message: stop forwarding synthetic message metadata to the model so
  bookkeeping annotations stay model-hidden.
- schema changelog entry for synthetic metadata and the metadata leak fix.
2026-07-01 20:50:10 -04:00
Dax Raad d544ab4d91 feat(tui): show background label on subagent line 2026-07-01 20:25:21 -04:00
Dax Raad ce228bfd7c feat(tui): maintain session family index in data context 2026-07-01 20:22:33 -04:00
Dax Raad eabf85aea2 fix(tui): wire subagent interrupt and drop granular background commands 2026-07-01 20:00:28 -04:00
Dax Raad 2f4a688790 chore: set up orchestrator plugin and mute prompt status labels 2026-07-01 18:51:18 -04:00
Dax Raad 4a90ffedfb fix(server): restore provider node deps after dev merge 2026-07-01 18:39:43 -04:00
Dax Raad 95cf5039be fix(tui): color prompt work counts 2026-07-01 18:23:07 -04:00
192 changed files with 9273 additions and 8655 deletions
+36
View File
@@ -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" })
})
})
},
}
-16
View File
@@ -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")
})
})
},
}
+2 -1
View File
@@ -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.
+104 -52
View File
@@ -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,72 @@ 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.context.entry.list"]>[0]
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
type Endpoint4_18Input = {
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
readonly key: Endpoint4_18Request["params"]["key"]
readonly value: Endpoint4_18Request["payload"]["value"]
}
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
raw["session.context.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly key: Endpoint4_19Request["params"]["key"]
}
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly limit?: Endpoint4_20Request["query"]["limit"]
readonly after?: Endpoint4_20Request["query"]["after"]
}
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
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_21Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly after?: Endpoint4_21Request["query"]["after"]
}
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
Stream.unwrap(
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
Effect.mapError(mapClientError),
@@ -232,22 +275,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) =>
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
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_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_24Input = {
readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
readonly messageID: Endpoint4_24Request["params"]["messageID"]
}
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -264,17 +307,21 @@ 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),
listContextEntries: Endpoint4_17(raw),
putContextEntry: Endpoint4_18(raw),
removeContextEntry: Endpoint4_19(raw),
history: Endpoint4_20(raw),
events: Endpoint4_21(raw),
interrupt: Endpoint4_22(raw),
background: Endpoint4_23(raw),
message: Endpoint4_24(raw),
})
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@@ -297,7 +344,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 = {
+68
View File
@@ -25,6 +25,8 @@ import type {
SessionPromptOutput,
SessionSkillInput,
SessionSkillOutput,
SessionSyntheticInput,
SessionSyntheticOutput,
SessionCompactInput,
SessionCompactOutput,
SessionWaitInput,
@@ -37,6 +39,12 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionListContextEntriesInput,
SessionListContextEntriesOutput,
SessionPutContextEntryInput,
SessionPutContextEntryOutput,
SessionRemoveContextEntryInput,
SessionRemoveContextEntryOutput,
SessionHistoryInput,
SessionHistoryOutput,
SessionEventsInput,
@@ -51,6 +59,8 @@ import type {
MessageListOutput,
ModelListInput,
ModelListOutput,
ModelDefaultInput,
ModelDefaultOutput,
GenerateTextInput,
GenerateTextOutput,
ProviderListInput,
@@ -461,6 +471,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>(
{
@@ -528,6 +550,40 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
listContextEntries: (input: SessionListContextEntriesInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionListContextEntriesOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
putContextEntry: (input: SessionPutContextEntryInput, requestOptions?: RequestOptions) =>
request<SessionPutContextEntryOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
body: { value: input["value"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
removeContextEntry: (input: SessionRemoveContextEntryInput, requestOptions?: RequestOptions) =>
request<SessionRemoveContextEntryOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
history: (input: SessionHistoryInput, requestOptions?: RequestOptions) =>
request<SessionHistoryOutput>(
{
@@ -613,6 +669,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) =>
+124
View File
@@ -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
@@ -787,6 +809,27 @@ export type SessionContextOutput = {
>
}["data"]
export type SessionListContextEntriesInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionListContextEntriesOutput = {
readonly data: ReadonlyArray<{ readonly key: string; readonly value: JsonValue }>
}["data"]
export type SessionPutContextEntryInput = {
readonly sessionID: { readonly sessionID: string; readonly key: string }["sessionID"]
readonly key: { readonly sessionID: string; readonly key: string }["key"]
readonly value: { readonly value: JsonValue }["value"]
}
export type SessionPutContextEntryOutput = void
export type SessionRemoveContextEntryInput = {
readonly sessionID: { readonly sessionID: string; readonly key: string }["sessionID"]
readonly key: { readonly sessionID: string; readonly key: string }["key"]
}
export type SessionRemoveContextEntryOutput = void
export type SessionHistoryInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"]
@@ -934,6 +977,7 @@ export type SessionHistoryOutput = {
readonly messageID: string
readonly text: string
readonly description?: string
readonly metadata?: { readonly [x: string]: JsonValue }
}
}
| {
@@ -1428,6 +1472,7 @@ export type SessionEventsOutput =
readonly messageID: string
readonly text: string
readonly description?: string
readonly metadata?: { readonly [x: string]: unknown }
}
}
| {
@@ -2169,12 +2214,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 +2238,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 +2341,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 +2375,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 +3615,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 +3653,7 @@ export type EventSubscribeOutput =
readonly messageID: string
readonly text: string
readonly description?: string
readonly metadata?: { readonly [x: string]: unknown }
}
}
| {
+222 -48
View File
@@ -1,8 +1,10 @@
{
"version": "7",
"dialect": "sqlite",
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8",
"prevIds": [
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
],
"ddl": [
{
"name": "workspace",
@@ -60,6 +62,10 @@
"name": "session_context_epoch",
"entityType": "tables"
},
{
"name": "session_context_entry",
"entityType": "tables"
},
{
"name": "session_input",
"entityType": "tables"
@@ -920,6 +926,56 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": false,
@@ -1481,9 +1537,13 @@
"table": "session_share"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1492,9 +1552,13 @@
"table": "workspace"
},
{
"columns": ["active_account_id"],
"columns": [
"active_account_id"
],
"tableTo": "account",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1503,9 +1567,13 @@
"table": "account_state"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"tableTo": "event_sequence",
"columnsTo": ["aggregate_id"],
"columnsTo": [
"aggregate_id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1514,9 +1582,13 @@
"table": "event"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1525,9 +1597,13 @@
"table": "permission"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1536,9 +1612,13 @@
"table": "project_directory"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1547,9 +1627,13 @@
"table": "message"
},
{
"columns": ["message_id"],
"columns": [
"message_id"
],
"tableTo": "message",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1558,9 +1642,13 @@
"table": "part"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1569,9 +1657,28 @@
"table": "session_context_epoch"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_context_entry_session_id_session_id_fk",
"entityType": "fks",
"table": "session_context_entry"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1580,9 +1687,13 @@
"table": "session_input"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1591,9 +1702,13 @@
"table": "session_message"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1602,9 +1717,13 @@
"table": "session"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1613,9 +1732,13 @@
"table": "todo"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1624,133 +1747,184 @@
"table": "session_share"
},
{
"columns": ["email", "url"],
"columns": [
"email",
"url"
],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["project_id", "directory"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": ["session_id", "position"],
"columns": [
"session_id",
"key"
],
"nameExplicit": false,
"name": "session_context_entry_pk",
"entityType": "pks",
"table": "session_context_entry"
},
{
"columns": [
"session_id",
"position"
],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": ["name"],
"columns": [
"name"
],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_context_epoch_pk",
"table": "session_context_epoch",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_input_pk",
"table": "session_input",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
@@ -2068,4 +2242,4 @@
}
],
"renames": []
}
}
+1
View File
@@ -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 -1
View File
@@ -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)
}
+1
View File
@@ -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
View File
@@ -40,5 +40,6 @@ export const migrations = (
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260702134641_add_session_context_entry"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,21 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260702134641_add_session_context_entry",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_context_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration
+11
View File
@@ -154,6 +154,17 @@ export default {
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_context_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_input\` (
\`id\` text PRIMARY KEY,
+4 -9
View File
@@ -1,21 +1,16 @@
import { buildLocationServiceMap } from "../location-services"
import { LocationServiceMap } from "../location-service-map"
import { PluginRuntime } from "../plugin/runtime"
import { LayerNode } from "./layer-node"
import { makeGlobalNode } from "./app-node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
const bridge = PluginRuntime.makeBridge()
let allReplacements = replacements.concat([
[PluginRuntime.node, PluginRuntime.nodeWithBridge(bridge)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithBridge(bridge)],
])
let allReplacements = replacements
// Only build the location service map if it's actually needed
if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(allReplacements, LocationServiceMap.node)) {
const locationMap = buildLocationServiceMap(allReplacements)
if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) {
const locationMap = buildLocationServiceMap(replacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
allReplacements = allReplacements.concat([[LocationServiceMap.node, locationMapNode]])
allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]])
}
return LayerNode.compile(root, allReplacements)
-1
View File
@@ -1 +0,0 @@
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
+22 -21
View File
@@ -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")
+6 -4
View File
@@ -36,9 +36,10 @@ 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 { BuiltInTools } from "./tool/builtins"
import { SessionContextEntry } from "./session/context-entry"
import { SessionInstructions } from "./session/instructions"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
@@ -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,
@@ -80,11 +81,12 @@ export const locationServices = LayerNode.group([
SkillGuidance.node,
ReferenceGuidance.node,
SessionTodo.node,
SessionContextEntry.node,
QuestionV2.node,
Generate.node,
ReadToolFileSystem.node,
BuiltInTools.node,
McpTool.node,
SessionInstructions.node,
SessionRunnerModel.node,
SessionCompaction.node,
SessionTitle.node,
+35 -14
View File
@@ -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.",
})
}),
+6 -1
View File
@@ -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 } {
+2
View File
@@ -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,
],
})
+43
View File
@@ -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) =>
+80 -52
View File
@@ -3,7 +3,7 @@ export * as PluginInternal from "./internal"
import { makeLocationNode } from "../effect/app-node"
import { httpClient } from "../effect/app-node-platform"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Layer, Scope } from "effect"
import { Context, Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
@@ -15,9 +15,11 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
import { FileMutation } from "../file-mutation"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { Image } from "../image"
import { Integration } from "../integration"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
@@ -26,7 +28,11 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { State } from "../state"
@@ -40,8 +46,20 @@ import { ProviderPlugins } from "./provider"
import { SdkPlugins } from "./sdk"
import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant"
import { ApplyPatchTool } from "../tool/apply-patch"
import { EditTool } from "../tool/edit"
import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep"
import { QuestionTool } from "../tool/question"
import { ReadTool } from "../tool/read"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { ShellTool } from "../tool/shell"
import { SkillTool } from "../tool/skill"
import { SubagentTool } from "../tool/subagent"
import { TodoWriteTool } from "../tool/todowrite"
import { WebFetchTool } from "../tool/webfetch"
import { WebSearchTool } from "../tool/websearch"
import { WriteTool } from "../tool/write"
export type Requirements =
| AgentV2.Service
@@ -49,10 +67,12 @@ export type Requirements =
| CommandV2.Service
| Config.Service
| EventV2.Service
| FileMutation.Service
| FileSystem.Service
| FSUtil.Service
| Global.Service
| HttpClient.HttpClient
| Image.Service
| Integration.Service
| Location.Service
| LocationMutation.Service
@@ -60,10 +80,16 @@ export type Requirements =
| Npm.Service
| PermissionV2.Service
| PluginRuntime.Service
| QuestionV2.Service
| ReadToolFileSystem.Service
| Reference.Service
| Ripgrep.Service
| SessionInstructions.Service
| SessionTodo.Service
| Shell.Service
| SkillV2.Service
| Tools.Service
| WebSearchTool.ConfigService
export interface Plugin<R = never> {
readonly id: string
@@ -76,59 +102,42 @@ export function define<R>(plugin: Plugin<R>) {
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service
const sdkPlugins = yield* SdkPlugins.Service
const integration = yield* Integration.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
const location = yield* Location.Service
const modelsDev = yield* ModelsDev.Service
const npm = yield* Npm.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const mutation = yield* LocationMutation.Service
const permission = yield* PermissionV2.Service
const skill = yield* SkillV2.Service
const reference = yield* Reference.Service
const shell = yield* Shell.Service
const tools = yield* Tools.Service
const runtime = yield* PluginRuntime.Service
const add = <R>(input: Plugin<R>) => {
const loaded = {
id: input.id,
effect: (context: PluginContext) =>
input
.effect(context)
.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Integration.Service, integration),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
Effect.provideService(Location.Service, location),
Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FileSystem.Service, filesystem),
Effect.provideService(Global.Service, global),
Effect.provideService(HttpClient.HttpClient, http),
Effect.provideService(LocationMutation.Service, mutation),
Effect.provideService(PermissionV2.Service, permission),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, reference),
Effect.provideService(Shell.Service, shell),
Effect.provideService(Tools.Service, tools),
Effect.provideService(PluginRuntime.Service, runtime),
),
}
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
}
const services = Context.mergeAll(
Context.make(Catalog.Service, yield* Catalog.Service),
Context.make(CommandV2.Service, yield* CommandV2.Service),
Context.make(Integration.Service, yield* Integration.Service),
Context.make(AgentV2.Service, yield* AgentV2.Service),
Context.make(Config.Service, yield* Config.Service),
Context.make(Location.Service, yield* Location.Service),
Context.make(ModelsDev.Service, yield* ModelsDev.Service),
Context.make(Npm.Service, yield* Npm.Service),
Context.make(EventV2.Service, yield* EventV2.Service),
Context.make(FSUtil.Service, yield* FSUtil.Service),
Context.make(FileSystem.Service, yield* FileSystem.Service),
Context.make(Global.Service, yield* Global.Service),
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
Context.make(FileMutation.Service, yield* FileMutation.Service),
Context.make(Image.Service, yield* Image.Service),
Context.make(PermissionV2.Service, yield* PermissionV2.Service),
Context.make(QuestionV2.Service, yield* QuestionV2.Service),
Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service),
Context.make(SessionInstructions.Service, yield* SessionInstructions.Service),
Context.make(SessionTodo.Service, yield* SessionTodo.Service),
Context.make(SkillV2.Service, yield* SkillV2.Service),
Context.make(Reference.Service, yield* Reference.Service),
Context.make(Ripgrep.Service, yield* Ripgrep.Service),
Context.make(Shell.Service, yield* Shell.Service),
Context.make(Tools.Service, yield* Tools.Service),
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
Context.make(WebSearchTool.ConfigService, yield* WebSearchTool.ConfigService),
)
const add = (input: Plugin<Requirements | Scope.Scope>) =>
plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) =>
input.effect(context).pipe(Effect.provide(services)),
)
yield* State.batch(
Effect.gen(function* () {
@@ -138,8 +147,19 @@ const layer = Layer.effectDiscard(
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ApplyPatchTool.Plugin)
yield* add(EditTool.Plugin)
yield* add(GlobTool.Plugin)
yield* add(GrepTool.Plugin)
yield* add(QuestionTool.Plugin)
yield* add(ReadTool.Plugin)
yield* add(ShellTool.Plugin)
yield* add(SkillTool.Plugin)
yield* add(SubagentTool.Plugin)
yield* add(TodoWriteTool.Plugin)
yield* add(WebFetchTool.Plugin)
yield* add(WebSearchTool.Plugin)
yield* add(WriteTool.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
@@ -165,6 +185,8 @@ export const node = makeLocationNode({
Config.node,
Location.node,
LocationMutation.node,
FileMutation.node,
Image.node,
ModelsDev.node,
Npm.node,
EventV2.node,
@@ -173,11 +195,17 @@ export const node = makeLocationNode({
Global.node,
httpClient,
PermissionV2.node,
QuestionV2.node,
ReadToolFileSystem.node,
SessionInstructions.node,
SessionTodo.node,
SkillV2.node,
Reference.node,
Ripgrep.node,
Shell.node,
ToolRegistry.toolsNode,
PluginRuntime.node,
SdkPlugins.node,
WebSearchTool.configNode,
],
})
+66 -18
View File
@@ -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
}
+37 -1
View File
@@ -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)
+38 -38
View File
@@ -1,6 +1,6 @@
export * as PluginRuntime from "./runtime"
import { Context, Deferred, Effect, Layer } from "effect"
import { Context, Effect, Layer } from "effect"
import { AgentV2 } from "../agent"
import { makeGlobalNode } from "../effect/app-node"
import { Job } from "../job"
@@ -25,44 +25,51 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginRuntime") {}
export interface Bridge {
runtime: Deferred.Deferred<Interface>
export interface Cell {
runtime?: Interface
}
export const makeBridge = (): Bridge => ({ runtime: Deferred.makeUnsafe<Interface>() })
export const makeCell = (): Cell => ({})
const require = <A, E, R>(bridge: Bridge, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
Effect.suspend(() => Deferred.await(bridge.runtime).pipe(Effect.flatMap(f)))
const unavailable = <A, E, R>() => Effect.die("Plugin runtime is unavailable") as Effect.Effect<A, E, R>
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const runtime = cell.runtime
if (runtime === undefined) return unavailable<A, E, R>()
return f(runtime)
})
export const layerWithBridge = (bridge: Bridge) =>
const defaultCell = makeCell()
export const layerWithCell = (cell: Cell) =>
Layer.succeed(
Service,
Service.of({
session: {
get: (sessionID) => require(bridge, (runtime) => runtime.session.get(sessionID)),
create: (input) => require(bridge, (runtime) => runtime.session.create(input)),
messages: (input) => require(bridge, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(bridge, (runtime) => runtime.session.prompt(input)),
resume: (sessionID) => require(bridge, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(bridge, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(bridge, (runtime) => runtime.session.synthetic(input)),
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
},
job: {
start: (input) => require(bridge, (runtime) => runtime.job.start(input)),
wait: (input) => require(bridge, (runtime) => runtime.job.wait(input)),
block: (input) => require(bridge, (runtime) => runtime.job.block(input)),
background: (id) => require(bridge, (runtime) => runtime.job.background(id)),
cancel: (id) => require(bridge, (runtime) => runtime.job.cancel(id)),
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
background: (id) => require(cell, (runtime) => runtime.job.background(id)),
cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)),
},
location: {
agent: {
list: (ref) => require(bridge, (runtime) => runtime.location.agent.list(ref)),
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
},
},
}),
)
export const providerLayerWithBridge = (bridge: Bridge) =>
export const providerLayerWithCell = (cell: Cell) =>
Layer.effectDiscard(
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
@@ -89,34 +96,27 @@ export const providerLayerWithBridge = (bridge: Bridge) =>
},
},
}
yield* Deferred.succeed(bridge.runtime, runtime)
cell.runtime = runtime
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
bridge.runtime = Deferred.makeUnsafe<Interface>()
if (cell.runtime === runtime) cell.runtime = undefined
}),
)
}),
)
const unsafeBridge = makeBridge()
export const layer = layerWithBridge(unsafeBridge)
export const providerLayer = providerLayerWithBridge(unsafeBridge)
export const layer = layerWithCell(defaultCell)
export const providerLayer = providerLayerWithCell(defaultCell)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
export const nodeWithBridge = (bridge: Bridge) =>
makeGlobalNode({ service: Service, layer: layerWithBridge(bridge), deps: [] })
export const providerNode = makeGlobalNode({
name: "plugin-runtime-provider",
layer: providerLayer,
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
})
export const providerNodeWithBridge = (bridge: Bridge) =>
// 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: providerLayerWithBridge(bridge),
layer: providerLayerWithCell(cell),
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
})
export const providerNode = providerNodeWithCell(defaultCell)
+2 -1
View File
@@ -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: {},
}))
}
+9 -1
View File
@@ -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
}
+36 -12
View File
@@ -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.",
})
}),
+9 -3
View File
@@ -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")
})
+106
View File
@@ -0,0 +1,106 @@
export * as SessionContextEntry from "./context-entry"
import { and, asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry"
import { Database } from "../database/database"
import { makeLocationNode } from "../effect/app-node"
import { SystemContext } from "../system-context/index"
import { SessionSchema } from "./schema"
import { SessionContextEntryTable } from "./sql"
export const Key = SessionContextEntry.Key
export type Key = typeof Key.Type
export const Info = SessionContextEntry.Info
export type Info = typeof Info.Type
export interface Interface {
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
readonly put: (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) => Effect.Effect<void>
readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect<void>
/** Produces one SystemContext source per stored entry, keyed `api/<key>`. */
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionContextEntry") {}
const renderValue = (value: Schema.Json) => (typeof value === "string" ? value : JSON.stringify(value, null, 2))
const renderBlock = (key: Key, value: Schema.Json) =>
[`<context key="${key}">`, renderValue(value), "</context>"].join("\n")
// Rendering stays mechanism-neutral: the model sees session context, not how
// it was attached. Only chronological updates and removals carry narration.
const source = (entry: Info) =>
SystemContext.make({
key: SystemContext.Key.make(`api/${entry.key}`),
codec: Schema.toCodecJson(Schema.Json),
load: Effect.succeed(entry.value),
baseline: (value) => renderBlock(entry.key, value),
update: (_previous, value) =>
[
`The context under "${entry.key}" changed and supersedes the previous value:`,
renderBlock(entry.key, value),
].join("\n"),
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("SessionContextEntry.list")(function* (sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(SessionContextEntryTable)
.where(eq(SessionContextEntryTable.session_id, sessionID))
.orderBy(asc(SessionContextEntryTable.key))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("SessionContextEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
yield* db
.insert(SessionContextEntryTable)
.values({ session_id: input.sessionID, key: input.key, value: input.value })
.onConflictDoUpdate({
target: [SessionContextEntryTable.session_id, SessionContextEntryTable.key],
set: { value: input.value, time_updated: Date.now() },
})
.run()
.pipe(Effect.orDie)
})
const remove = Effect.fn("SessionContextEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
yield* db
.delete(SessionContextEntryTable)
.where(
and(eq(SessionContextEntryTable.session_id, input.sessionID), eq(SessionContextEntryTable.key, input.key)),
)
.run()
.pipe(Effect.orDie)
})
const load = Effect.fn("SessionContextEntry.load")(function* (sessionID: SessionSchema.ID) {
const entries = yield* list(sessionID)
return SystemContext.combine(entries.map(source))
})
return Service.of({ list, put, remove, load })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Database.node] })
-174
View File
@@ -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")
})
-12
View File
@@ -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}`
}
}
+7 -12
View File
@@ -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,
+115
View File
@@ -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 },
+34 -6
View File
@@ -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)
}),
)
}),
+1 -2
View File
@@ -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
+61 -15
View File
@@ -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,15 @@ 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 { SessionContextEntry } from "../context-entry"
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"
@@ -102,10 +104,12 @@ 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
const contextEntries = yield* SessionContextEntry.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@@ -169,10 +173,18 @@ const layer = Layer.effect(
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))
const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
Effect.all(
[
builtins.load(),
instructions.load(),
skillGuidance.load(agent),
referenceGuidance.load(),
mcpGuidance.load(agent),
contextEntries.load(sessionID),
],
{ concurrency: "unbounded" },
).pipe(Effect.map(SystemContext.combine))
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
@@ -184,7 +196,14 @@ 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),
session.id,
)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false
let currentStep = step
@@ -198,10 +217,8 @@ const layer = Layer.effect(
}
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,7 +228,10 @@ 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)] : [])],
@@ -387,7 +407,7 @@ const layer = Layer.effect(
)
})
const run = Effect.fn("SessionRunner.run")(function* (input: {
const drain = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
}) {
@@ -418,6 +438,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,10 +479,12 @@ export const node = makeLocationNode({
SessionRunnerModel.node,
SessionStore.node,
Location.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node,
InstructionContext.node,
SkillGuidance.node,
ReferenceGuidance.node,
McpGuidance.node,
SessionContextEntry.node,
SessionCompaction.node,
SessionTitle.node,
Config.node,
+28
View File
@@ -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":
+17 -2
View File
@@ -14,6 +14,7 @@ import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context/index"
import { AgentV2 } from "../agent"
import type { Revert } from "@opencode-ai/schema/revert"
import type { Schema } from "effect"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@@ -165,12 +166,26 @@ export const SessionInputTable = sqliteTable(
],
)
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
export const SessionContextEntryTable = sqliteTable(
"session_context_entry",
{
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
key: text().notNull(),
value: text({ mode: "json" }).notNull().$type<Schema.Json>(),
...Timestamps,
},
(table) => [primaryKey({ columns: [table.session_id, table.key] })],
)
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(),
})
-7
View File
@@ -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()
+33 -13
View File
@@ -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.",
})
}),
+11 -13
View File
@@ -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] })
+149 -162
View File
@@ -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: [] })
+7 -15
View File
@@ -1,18 +1,16 @@
export * as ApplyPatchTool from "./apply-patch"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "apply_patch"
@@ -56,15 +54,15 @@ type Prepared =
readonly after: string
})
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-apply-patch-tool",
effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.withPermission(
Tool.make({
@@ -194,13 +192,7 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/apply-patch",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
}
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
-53
View File
@@ -1,53 +0,0 @@
export * as BuiltInTools from "./builtins"
import { makeLocationNode } from "../effect/app-node"
import { Context, Layer } from "effect"
import { ApplyPatchTool } from "./apply-patch"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { QuestionTool } from "./question"
import { ReadTool } from "./read"
import { ReadToolFileSystem } from "./read-filesystem"
import { SkillTool } from "./skill"
import { TodoWriteTool } from "./todowrite"
import { WebFetchTool } from "./webfetch"
import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write"
export class Service extends Context.Service<Service, Record<string, never>>()("@opencode/v2/BuiltInTools") {}
/**
* Composes only the shipped Location-scoped built-in tool transforms.
* Each tool retains its implementation and focused tests independently. Dynamic
* MCP and plugin tools later use separate scoped canonical registrations, while
* provider/model filtering belongs to a future materialization phase rather
* than this static list. The caller intentionally supplies shared Location
* services once to this merged set.
*
* TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
* parity, task, LSP,
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
* transforms separate from this static built-in list.
*/
const layer = Layer.succeed(Service, Service.of({}))
export const node = makeLocationNode({
service: Service,
layer,
deps: [
ApplyPatchTool.node,
EditTool.node,
GlobTool.node,
GrepTool.node,
QuestionTool.node,
ReadTool.node,
ReadToolFileSystem.node,
SkillTool.node,
TodoWriteTool.node,
WebFetchTool.node,
WebSearchTool.node,
WebSearchTool.configNode,
WriteTool.node,
],
})
+7 -15
View File
@@ -6,18 +6,16 @@
*/
export * as EditTool from "./edit"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "edit"
@@ -87,15 +85,15 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-edit-tool",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.withPermission(
Tool.make({
@@ -214,10 +212,4 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/edit",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
}
+7 -15
View File
@@ -1,17 +1,15 @@
export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
import path from "path"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "glob"
@@ -35,14 +33,14 @@ export const toModelOutput = (output: ModelOutput) => {
}
/** Glob leaf that defaults its filesystem root to the active Location. */
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-glob-tool",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.make({
description:
@@ -96,10 +94,4 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/glob",
layer,
deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node],
})
}
+7 -15
View File
@@ -1,18 +1,16 @@
export * as GrepTool from "./grep"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import path from "path"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "grep"
@@ -50,15 +48,15 @@ export const toModelOutput = (output: ModelOutput) => {
}
/** Grep leaf that defaults its filesystem root to the active Location. */
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-grep-tool",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.make({
description:
@@ -128,10 +126,4 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/grep",
layer,
deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
}
+92
View File
@@ -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: [] })
+7 -15
View File
@@ -1,13 +1,11 @@
export * as QuestionTool from "./question"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { Effect, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "question"
@@ -44,13 +42,13 @@ export const toModelOutput = (
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
}
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-question-tool",
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
const question = yield* QuestionV2.Service
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.make({
description,
@@ -85,10 +83,4 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/question",
layer,
deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
})
}
+45 -21
View File
@@ -1,19 +1,22 @@
export * as ReadTool from "./read"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { dirname } from "path"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { Effect, Schema } from "effect"
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"
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,
@@ -27,15 +30,18 @@ const LocationInput = Schema.Struct({
const Input = LocationInput
const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-read-tool",
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
const reader = yield* ReadToolFileSystem.Service
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
yield* ctx.tool
.register({
[name]: Tool.make({
description:
@@ -77,12 +83,36 @@ 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" })
@@ -108,10 +138,4 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/read",
layer,
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
})
}
+47 -12
View File
@@ -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],
})
+7 -15
View File
@@ -1,15 +1,13 @@
export * as SkillTool from "./skill"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import path from "path"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { Effect, Schema } from "effect"
import { FSUtil } from "../fs-util"
import { SkillV2 } from "../skill"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "skill"
const FILE_LIMIT = 10
@@ -54,13 +52,13 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>)
const unableToLoad = (name: string, error?: unknown) =>
new ToolFailure({ message: `Unable to load skill ${name}`, error })
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-skill-tool",
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const skills = yield* SkillV2.Service
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.make({
description,
@@ -100,10 +98,4 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/skill",
layer,
deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node],
})
}
+7 -15
View File
@@ -1,13 +1,11 @@
export * as TodoWriteTool from "./todowrite"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { Effect, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { SessionTodo } from "../session/todo"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "todowrite"
@@ -22,13 +20,13 @@ export type Output = typeof Output.Type
export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-todowrite-tool",
effect: Effect.fn("TodoWriteTool.Plugin")(function* (ctx: PluginContext) {
const todos = yield* SessionTodo.Service
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.make({
description:
@@ -53,10 +51,4 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/todowrite",
layer,
deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node],
})
}
+7 -16
View File
@@ -1,17 +1,14 @@
export * as WebFetchTool from "./webfetch"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform"
import { PermissionV2 } from "../permission"
import { collectBoundedResponseBody } from "./http-body"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "webfetch"
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
@@ -115,13 +112,13 @@ const convert = (content: string, contentType: string, format: Format) => {
return content
}
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-webfetch-tool",
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
const http = yield* HttpClient.HttpClient
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.make({
description,
@@ -178,13 +175,7 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/webfetch",
layer,
deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient],
})
}
export function extractTextFromHTML(html: string) {
let text = ""
+6 -14
View File
@@ -1,19 +1,17 @@
export * as WebSearchTool from "./websearch"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform"
import { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode"
import { ToolRegistry } from "./registry"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
@@ -189,14 +187,14 @@ const Output = Schema.Struct({
text: Schema.String,
})
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-websearch-tool",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const http = yield* HttpClient.HttpClient
const config = yield* ConfigService
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.make({
description,
@@ -251,10 +249,4 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/websearch",
layer,
deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode],
})
}
+7 -15
View File
@@ -6,15 +6,13 @@
*/
export * as WriteTool from "./write"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "write"
@@ -44,14 +42,14 @@ export const toModelOutput = (output: Output) =>
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
export const Plugin = {
id: "core-write-tool",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const permission = yield* PermissionV2.Service
yield* tools
yield* ctx.tool
.register({
[name]: Tool.withPermission(
Tool.make({
@@ -92,10 +90,4 @@ const layer = Layer.effectDiscard(
})
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/write",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node],
})
}
+1
View File
@@ -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" },
})
+16 -17
View File
@@ -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({
+26 -1
View File
@@ -2,7 +2,9 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Effect } from "effect"
import { Tools } from "@opencode-ai/core/tool/tools"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, type Scope } from "effect"
export const toolIdentity = {
agent: AgentV2.ID.make("build"),
@@ -34,6 +36,29 @@ export function waitForTool(
})
}
/**
* Registers a core tool plugin's tools against the real registry without booting the
* full plugin host. Only the tool domain is live; focused tool tests exercise
* registration, materialization, and settlement through the same path production uses.
*/
export const registerToolPlugin = <R>(plugin: {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
Effect.gen(function* () {
const tools = yield* Tools.Service
const context: Pick<PluginContext, "tool"> = {
tool: {
register: tools.register,
execute: {
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
},
}
yield* plugin.effect(context as PluginContext)
})
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
+20 -5
View File
@@ -24,9 +24,7 @@ import { EventV2 } from "../src/event"
import { Reference } from "../src/reference"
import { ToolRegistry } from "../src/tool/registry"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
describe("LocationServiceMap", () => {
it.live("reuses cached services for constructed and decoded location refs", () =>
@@ -75,8 +73,25 @@ describe("LocationServiceMap", () => {
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "shell")
yield* waitForTool(registry, "subagent")
// Tool plugins register during the forked PluginInternal boot; wait for
// every expected tool rather than relying on batch ordering.
yield* Effect.forEach(
[
"edit",
"glob",
"grep",
"question",
"read",
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",
"write",
],
(name) => waitForTool(registry, name),
)
return {
providers: yield* catalog.provider.all(),
tools: yield* toolDefinitions(registry),
+66
View File
@@ -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: [] })
}),
)
})
+2
View File
@@ -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 }
}
}
}
}
+16 -2
View File
@@ -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 },
})),
+62 -49
View File
@@ -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"), () => {})
})
+4 -4
View File
@@ -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" } }),
])
}),
)
+43 -6
View File
@@ -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) }))))
})
})
@@ -0,0 +1,321 @@
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 { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { registerToolPlugin, settleTool, testModel } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
deps: [
ToolRegistry.toolsNode,
ReadToolFileSystem.node,
LocationMutation.node,
Image.node,
PermissionV2.node,
SessionInstructions.node,
FSUtil.node,
Location.node,
],
})
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,
readToolNode,
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()
})
})
+17 -1
View File
@@ -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()
}),
)
@@ -248,6 +259,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 +330,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) },
+112 -10
View File
@@ -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],
+144 -55
View File
@@ -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,16 @@ 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 { SessionContextEntry } from "@opencode-ai/core/session/context-entry"
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"
@@ -169,35 +170,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 +230,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 +269,9 @@ const it = testEffect(
ToolRegistry.toolsNode,
echoNode,
SessionRunnerModel.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node,
InstructionContext.node,
SessionContextEntry.node,
SkillGuidance.node,
ReferenceGuidance.node,
Config.node,
@@ -287,7 +284,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],
@@ -699,8 +697,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()
@@ -731,8 +729,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 +743,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 +781,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 +823,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,14 +1085,66 @@ 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)
}),
)
it.effect("renders API context entries through the belief lifecycle", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const contextEntries = yield* SessionContextEntry.Service
yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
// String values render verbatim inside the tagged block at baseline.
expect(requests[0]?.system.map((part) => part.text)).toEqual([
defaultSystem,
["Initial context", "", '<context key="deploy-target">', "production", "</context>"].join("\n"),
])
// Non-string JSON pretty-prints; the change narrates as a System update.
yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } })
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", "system", "user"])
expect(requests[1]?.messages.at(1)?.content).toEqual([
{
type: "text",
text: [
'The context under "deploy-target" changed and supersedes the previous value:',
'<context key="deploy-target">',
"{",
' "region": "us-east-1"',
"}",
"</context>",
].join("\n"),
},
])
expect(yield* contextEntries.list(sessionID)).toEqual([{ key: "deploy-target", value: { region: "us-east-1" } }])
// Deleting the row announces removal through the stored removal text.
yield* contextEntries.remove({ sessionID, key: "deploy-target" })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"])
expect(requests[2]?.messages.at(-2)?.content).toEqual([
{ type: "text", text: 'The context under "deploy-target" no longer applies. Disregard it.' },
])
expect(yield* contextEntries.list(sessionID)).toEqual([])
}),
)
it.effect("keeps the baseline and chronological System updates after a model switch", () =>
Effect.gen(function* () {
yield* setup
@@ -1085,15 +1173,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 +1449,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 +1481,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")
}),
)
+77 -11
View File
@@ -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>",
+123 -51
View File
@@ -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: {} })
}),
)
})
+9 -2
View File
@@ -16,8 +16,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const applyPatchToolNode = makeLocationNode({
name: "test/apply-patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(ApplyPatchTool.Plugin)),
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@@ -101,7 +108,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
ApplyPatchTool.node,
applyPatchToolNode,
]),
[
[FSUtil.node, filesystem],
+9 -2
View File
@@ -17,8 +17,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { EditTool } from "@opencode-ai/core/tool/edit"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_edit_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@@ -91,7 +98,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
EditTool.node,
editToolNode,
]),
[
[FSUtil.node, filesystem],
+9 -2
View File
@@ -9,7 +9,8 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@@ -43,8 +44,14 @@ const question = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const questionToolNode = makeLocationNode({
name: "test/question-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, QuestionV2.node],
})
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
[PermissionV2.node, permission],
[QuestionV2.node, question],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+19 -2
View File
@@ -19,8 +19,25 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
deps: [
ToolRegistry.toolsNode,
ReadToolFileSystem.node,
LocationMutation.node,
Image.node,
PermissionV2.node,
SessionInstructions.node,
FSUtil.node,
Location.node,
],
})
const assertions: PermissionV2.AssertInput[] = []
const missingPath = "__missing_read_target__.txt"
@@ -130,7 +147,7 @@ const unavailableImage = Layer.succeed(
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, readToolNode]), [
[ReadToolFileSystem.node, reader],
[PermissionV2.node, permission],
[Config.node, config],
+10 -2
View File
@@ -13,7 +13,15 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const skillToolNode = makeLocationNode({
name: "test/skill-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(SkillTool.Plugin)),
deps: [ToolRegistry.toolsNode, FSUtil.node, SkillV2.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
@@ -66,7 +74,7 @@ describe("SkillTool", () => {
}),
)
const skillToolLayer = AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]),
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, skillToolNode]),
[
[PermissionV2.node, permission],
[SkillV2.node, skills],
+9 -2
View File
@@ -15,7 +15,14 @@ import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const todoWriteToolNode = makeLocationNode({
name: "test/todowrite-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(TodoWriteTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, SessionTodo.node],
})
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@@ -43,7 +50,7 @@ const it = testEffect(
SessionTodo.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
TodoWriteTool.node,
todoWriteToolNode,
]),
[
[PermissionV2.node, permission],
+9 -2
View File
@@ -10,8 +10,15 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const webFetchToolNode = makeLocationNode({
name: "test/webfetch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WebFetchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient],
})
const sessionID = SessionV2.ID.make("ses_webfetch_test")
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
@@ -40,7 +47,7 @@ const permission = Layer.succeed(
}),
)
const toolLayer = (replacements: LayerNode.Replacements = []) =>
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, webFetchToolNode]), [
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
...replacements,
+9 -2
View File
@@ -9,8 +9,15 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
})
const sessionID = SessionV2.ID.make("ses_websearch_test")
const payload = (text: string) =>
@@ -125,7 +132,7 @@ const websearchConfig = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]),
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
[
[PermissionV2.node, permission],
[LayerNodePlatform.httpClient, http],

Some files were not shown because too many files have changed in this diff Show More