Compare commits

...

29 Commits

Author SHA1 Message Date
Kit Langton 67c0c79613 test(core): update session input promotion calls 2026-07-02 21:22:54 -04:00
Kit Langton 603fa77705 fix: restore repo typecheck after provider request settings change
The required Provider.Request settings field and tsgo's literal
narrowing broke typecheck in llm and core; CI missed it because turbo
replayed cached typecheck results for packages whose own sources did
not change.

- anthropic-messages: lower adaptive thinking display with explicit
  literal branches so tsgo keeps the union type
- runner model: annotate providerOptions with its index-signature
  return type
- tests: construct provider requests with the now-required settings
  field and copy them mutably for plugin drafts
2026-07-02 21:21:11 -04:00
Kit Langton 3898cac63d refactor(core): extract SystemContext.diffByKey for delta renderers
Skill, reference, and MCP guidance shared the same keyed three-way diff
mechanics; each keeps only its own changed comparator and phrasing.
2026-07-02 21:21:11 -04:00
Kit Langton 2fee823dbf docs: describe the context checkpoint belief model 2026-07-02 21:21:11 -04:00
Kit Langton 334613c18f feat(core): render skill, reference, and MCP context updates as deltas
Pure additions announce only the new entries and pure removals name
what is gone, instead of restating the full list on every change.
Description or instruction edits still restate the superseding list.
2026-07-02 21:21:11 -04:00
Kit Langton aa2c1472fa fix(core): copy the context checkpoint when forking a session
Copied fork messages keep their parent seqs, but a fresh checkpoint's
baseline_seq starts near zero, so nothing folded: forks received a new
full baseline plus every stale context-update message from the parent,
and copied compaction rows triggered a spurious rebaseline. Inheriting
the parent row keeps the fold horizon consistent with the copied
transcript.

Also asserts the checkpoint reset on committed reverts and deletes the
unused SessionStore.runnerContext / SessionHistory.loadForRunner
surface.
2026-07-02 21:21:11 -04:00
Kit Langton cfdb9ab705 refactor(core): delete the system context registry
Builtins and instruction context become ordinary Location-scoped
services exposing load(), the same shape as skill, reference, and MCP
guidance. The runner composes all six producers explicitly in
loadSystemContext, so the full context roster is readable at one call
site. Dynamic registration and scoped deregistration had no consumers.
2026-07-02 21:21:11 -04:00
Kit Langton 67b4b2894f refactor(core): replace context epoch with a single-entry checkpoint
One prepare call per turn, before input promotion: it creates the
checkpoint when missing, rebaselines after completed compaction, and
otherwise narrates drift as a chronological update. The initialize/
prepare two-step and its redundant queries are gone, and context update
messages now precede the promoted user message.

An undecodable stored applied record heals by treating every source as
new instead of failing the drain; ContextSnapshotDecodeError is deleted.

Renames: SessionContextEpoch -> SessionContextCheckpoint. The SQL table
name and wire event type are unchanged.
2026-07-02 21:21:11 -04:00
Kit Langton bf5294121a refactor(core): simplify system context to a belief model
Reconcile never rewrites the baseline; only rebaseline (compaction) and
initialize (first turn) produce baseline text. The durable Applied record
tracks what the model was last told, per source.

- Incompatible stored values re-announce the source baseline as an update
  instead of forcing a full replacement
- Sources removed without removal text retain the model's belief silently;
  entries self-clean at the next rebaseline
- Rebaseline restates unobservable sources from their last-applied values
  instead of blocking; ReplacementBlocked and ReplacementReady are gone
- Rename Snapshot to Applied and Generation to Baseline {text, applied}
2026-07-02 21:20:38 -04:00
Kit Langton 7d598b5610 refactor(core): drop synchronous drain start from the coordinator
Drains now uniformly start one tick after wake or run, removing the
ready-deferred release that ran the first drain synchronously inside
the caller. The synchronous start was observable only by tests that
asserted state immediately after fire-and-forget calls, coupling them
to scheduler internals; those tests now synchronize explicitly through
session.wait or stream-start signals.
2026-07-02 21:15:15 -04:00
Kit Langton fae51d853d refactor(core): dedupe context epoch initialization
Share one insertInitial path between initialize and prepare, and
document the runner's ordering constraint: epoch initialization runs
before promotion so a blocked System Context leaves prompts pending,
while reconciliation runs after promotion so ContextUpdated messages
sequence after promoted input.
2026-07-02 20:56:56 -04:00
Kit Langton 966dc54e1d refactor(core): rewrite run coordinator as an execution loop
One fiber now owns each execution (busy period) and consumes coalesced
doorbell wakes as a loop condition, replacing per-drain fiber chaining
with owner reassignment and entry reuse. Initial starts stay synchronous
so a prompt's wake reaches the provider in the same tick; successor
starts defer one tick so failing self-waking executions cannot grow the
stack.
2026-07-02 20:56:56 -04:00
Kit Langton 606de48d8b refactor(core): simplify prompt inbox promotion and projection
Remove the steer promotion cutoff: a turn boundary now promotes every
steer pending at promotion time instead of snapshotting the inbox at a
captured sequence, so late-arriving steers ride along into the turn.

Delete legacy projected-prompt synthesis from projectPrompted. The
event-sourced session input migration wiped pre-inbox event history,
so every Prompted event follows an admitted inbox row and a missing or
divergent row on replay is an invariant violation.

Inline the prompt equivalence predicate and update tests that seeded
history with bare Prompted events to publish PromptAdmitted first.
2026-07-02 16:46:16 -04:00
Kit Langton 0d1ef5adb6 fix(core): fail drains on tool output persistence failures
Restructure runner turn settlement: replace TurnTransitionError defect
control flow with tagged turn results and a plain restart loop, name the
settlement evidence, and merge duplicated interrupt branches.

Split settled tool-fiber failures: tool implementation defects keep
surfacing as model-visible tool errors and the turn continues, while
typed ToolOutputStore errors now fail the assistant and the drain
instead of being silently absorbed.
2026-07-02 16:19:46 -04:00
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
164 changed files with 8406 additions and 8503 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.
+71 -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,42 @@ const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12I
Effect.map((value) => value.data),
)
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint4_16Input = {
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
readonly limit?: Endpoint4_16Request["query"]["limit"]
readonly after?: Endpoint4_16Request["query"]["after"]
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint4_17Input = {
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
readonly limit?: Endpoint4_17Request["query"]["limit"]
readonly after?: Endpoint4_17Request["query"]["after"]
}
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
raw["session.history"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], after: input["after"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint4_17Input = {
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
readonly after?: Endpoint4_17Request["query"]["after"]
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint4_18Input = {
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
readonly after?: Endpoint4_18Request["query"]["after"]
}
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
Stream.unwrap(
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
Effect.mapError(mapClientError),
@@ -232,22 +245,22 @@ const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17I
),
)
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly messageID: Endpoint4_20Request["params"]["messageID"]
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly messageID: Endpoint4_21Request["params"]["messageID"]
}
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -264,17 +277,18 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
rename: Endpoint4_7(raw),
prompt: Endpoint4_8(raw),
skill: Endpoint4_9(raw),
compact: Endpoint4_10(raw),
wait: Endpoint4_11(raw),
revertStage: Endpoint4_12(raw),
revertClear: Endpoint4_13(raw),
revertCommit: Endpoint4_14(raw),
context: Endpoint4_15(raw),
history: Endpoint4_16(raw),
events: Endpoint4_17(raw),
interrupt: Endpoint4_18(raw),
background: Endpoint4_19(raw),
message: Endpoint4_20(raw),
synthetic: Endpoint4_10(raw),
compact: Endpoint4_11(raw),
wait: Endpoint4_12(raw),
revertStage: Endpoint4_13(raw),
revertClear: Endpoint4_14(raw),
revertCommit: Endpoint4_15(raw),
context: Endpoint4_16(raw),
history: Endpoint4_17(raw),
events: Endpoint4_18(raw),
interrupt: Endpoint4_19(raw),
background: Endpoint4_20(raw),
message: Endpoint4_21(raw),
})
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@@ -297,7 +311,12 @@ type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["locat
const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) =>
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw) })
type Endpoint6_1Request = Parameters<RawClient["server.model"]["model.default"]>[0]
type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] }
const Endpoint6_1 = (raw: RawClient["server.model"]) => (input?: Endpoint6_1Input) =>
raw["model.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw), default: Endpoint6_1(raw) })
type Endpoint7_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
type Endpoint7_0Input = {
+28
View File
@@ -25,6 +25,8 @@ import type {
SessionPromptOutput,
SessionSkillInput,
SessionSkillOutput,
SessionSyntheticInput,
SessionSyntheticOutput,
SessionCompactInput,
SessionCompactOutput,
SessionWaitInput,
@@ -51,6 +53,8 @@ import type {
MessageListOutput,
ModelListInput,
ModelListOutput,
ModelDefaultInput,
ModelDefaultOutput,
GenerateTextInput,
GenerateTextOutput,
ProviderListInput,
@@ -461,6 +465,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
synthetic: (input: SessionSyntheticInput, requestOptions?: RequestOptions) =>
request<SessionSyntheticOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`,
body: { text: input["text"], description: input["description"], metadata: input["metadata"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
request<SessionCompactOutput>(
{
@@ -613,6 +629,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
default: (input?: ModelDefaultInput, requestOptions?: RequestOptions) =>
request<ModelDefaultOutput>(
{
method: "GET",
path: `/api/model/default`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
},
generate: {
text: (input: GenerateTextInput, requestOptions?: RequestOptions) =>
+103
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
@@ -934,6 +956,7 @@ export type SessionHistoryOutput = {
readonly messageID: string
readonly text: string
readonly description?: string
readonly metadata?: { readonly [x: string]: JsonValue }
}
}
| {
@@ -1428,6 +1451,7 @@ export type SessionEventsOutput =
readonly messageID: string
readonly text: string
readonly description?: string
readonly metadata?: { readonly [x: string]: unknown }
}
}
| {
@@ -2169,12 +2193,14 @@ export type ModelListOutput = {
readonly output: ReadonlyArray<string>
}
readonly request: {
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
readonly variant?: string
}
readonly variants: ReadonlyArray<{
readonly id: string
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
}>
@@ -2191,6 +2217,67 @@ export type ModelListOutput = {
}>
}
export type ModelDefaultInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ModelDefaultOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly id: string
readonly providerID: string
readonly family?: string
readonly name: string
readonly api:
| {
readonly id: string
readonly type: "aisdk"
readonly package: string
readonly url?: string
readonly settings?: { readonly [x: string]: JsonValue }
}
| {
readonly id: string
readonly type: "native"
readonly url?: string
readonly settings: { readonly [x: string]: JsonValue }
}
readonly capabilities: {
readonly tools: boolean
readonly input: ReadonlyArray<string>
readonly output: ReadonlyArray<string>
}
readonly request: {
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
readonly variant?: string
}
readonly variants: ReadonlyArray<{
readonly id: string
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
}>
readonly time: { readonly released: number }
readonly cost: ReadonlyArray<{
readonly tier?: { readonly type: "context"; readonly size: number }
readonly input: number
readonly output: number
readonly cache: { readonly read: number; readonly write: number }
}>
readonly status: "alpha" | "beta" | "deprecated" | "active"
readonly enabled: boolean
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
} | null
}
export type GenerateTextInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -2233,6 +2320,7 @@ export type ProviderListOutput = {
}
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
readonly request: {
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
}
@@ -2266,6 +2354,7 @@ export type ProviderGetOutput = {
}
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
readonly request: {
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
}
@@ -3505,6 +3594,19 @@ export type EventSubscribeOutput =
readonly delivery: "steer" | "queue"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.execution.settled"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly outcome: "success" | "failure" | "interrupted"
readonly error?: { readonly type: "unknown"; readonly message: string }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
@@ -3530,6 +3632,7 @@ export type EventSubscribeOutput =
readonly messageID: string
readonly text: string
readonly description?: string
readonly metadata?: { readonly [x: string]: unknown }
}
}
| {
+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
@@ -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")
+4 -2
View File
@@ -36,8 +36,9 @@ import { SessionTodo } from "./session/todo"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { Snapshot } from "./snapshot"
import { InstructionContext } from "./instruction-context"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { SystemContextRegistry } from "./system-context/registry"
import { SessionInstructions } from "./session/instructions"
import { BuiltInTools } from "./tool/builtins"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
@@ -67,8 +68,8 @@ export const locationServices = LayerNode.group([
Pty.node,
Shell.node,
SkillV2.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node,
InstructionContext.node,
LocationMutation.node,
FileMutation.node,
MCP.node,
@@ -85,6 +86,7 @@ export const locationServices = LayerNode.group([
ReadToolFileSystem.node,
BuiltInTools.node,
McpTool.node,
SessionInstructions.node,
SessionRunnerModel.node,
SessionCompaction.node,
SessionTitle.node,
+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) =>
+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)
+10 -5
View File
@@ -110,8 +110,13 @@ export const providerLayer = providerLayerWithCell(defaultCell)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
export const providerNode = makeGlobalNode({
name: "plugin-runtime-provider",
layer: providerLayer,
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
})
// Raw layer replacements are compiled without dependencies, so cell-scoped
// provider replacements must go through this node to keep their deps wired.
export const providerNodeWithCell = (cell: Cell) =>
makeGlobalNode({
name: "plugin-runtime-provider",
layer: providerLayerWithCell(cell),
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
})
export const providerNode = providerNodeWithCell(defaultCell)
+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")
})
-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,
+7 -25
View File
@@ -1,6 +1,6 @@
export * as SessionInput from "./input"
import { and, asc, eq, isNull, lte } from "drizzle-orm"
import { and, asc, eq, isNull } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database"
@@ -145,26 +145,11 @@ export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(functio
return
}
// Every Prompted event is published from an admitted inbox row, so a missing or
// divergent row on replay is an invariant violation.
const stored = yield* find(db, input.id)
if (stored) {
if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq)
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return
}
yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
admitted_seq: input.promotedSeq,
promoted_seq: input.promotedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.run()
.pipe(Effect.orDie)
if (!stored || !matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq)
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
@@ -195,9 +180,8 @@ export const equivalent = (
readonly prompt: Prompt
readonly delivery: Delivery
},
) => input.delivery === expected.delivery && matchesPrompt(input, expected)
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
) =>
input.delivery === expected.delivery &&
input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
@@ -246,7 +230,6 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
cutoff: number,
) {
const rows = yield* db
.select()
@@ -256,7 +239,6 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
eq(SessionInputTable.session_id, sessionID),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "steer"),
lte(SessionInputTable.admitted_seq, cutoff),
),
)
.orderBy(asc(SessionInputTable.admitted_seq))
+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)
}),
)
}),
+60 -60
View File
@@ -6,19 +6,26 @@ import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
export interface Coordinator<Key, E> {
/** Snapshots keys with an execution owned by this coordinator. */
readonly active: Effect.Effect<ReadonlySet<Key>>
/** Starts execution while idle or joins the active execution. */
/** Starts an execution while idle or joins the active execution. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Registers one coalesced follow-up after newly recorded work. */
readonly wake: (key: Key) => Effect.Effect<void>
/** Stops active execution and waits for its cleanup. */
/** Stops the active execution and waits for its cleanup. */
readonly interrupt: (key: Key) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
type Entry<E> = {
/**
* One execution is a busy period for one key: one fiber that drains from the first wake
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
* execution rings it, and the execution loop drains again instead of ending. The doorbell
* closes the gap between a drain's last eligibility check and the idle transition, since
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
*/
type Execution<E> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void, never>
owner?: Fiber.Fiber<void>
pendingWake: boolean
stopping: boolean
}
@@ -27,90 +34,83 @@ export const make = <Key, E>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
Effect.gen(function* () {
const active = new Map<Key, Entry<E>>()
const executions = new Map<Key, Execution<E>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const makeEntry = (): Entry<E> => ({
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
stopping: false,
})
const loop = (key: Key, execution: Execution<E>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe(
Effect.flatMap(() =>
Effect.suspend(() => {
if (execution.stopping || !execution.pendingWake) return Effect.void
execution.pendingWake = false
// Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
}),
),
)
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
const ready = Deferred.makeUnsafe<void>()
const owner = fork(
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
Effect.andThen(Effect.suspend(() => options.drain(key, force))),
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
const start = (key: Key, force: boolean) => {
const execution: Execution<E> = { done: Deferred.makeUnsafe<void, E>(), pendingWake: false, stopping: false }
executions.set(key, execution)
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
// failing self-waking executions from growing the stack across successor starts.
// Drains start one tick after wake; callers observe progress through events or run.
execution.owner = fork(
Effect.yieldNow.pipe(
Effect.andThen(loop(key, execution, force)),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.exit,
Effect.asVoid,
),
)
entry.owner = owner
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
return execution
}
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
entry.pendingWake = false
start(key, entry, false, true)
return
}
const successor = entry.pendingWake ? makeEntry() : undefined
if (successor === undefined) active.delete(key)
else {
active.set(key, successor)
start(key, successor, false, true)
}
Deferred.doneUnsafe(entry.done, exit)
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
}
const run = (key: Key): Effect.Effect<void, E> =>
Effect.uninterruptibleMask((restore) => {
const entry = active.get(key)
if (entry !== undefined) {
if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
return restore(Deferred.await(entry.done))
const execution = executions.get(key)
if (execution !== undefined) {
if (execution.stopping) return restore(Deferred.await(execution.done).pipe(Effect.andThen(run(key))))
return restore(Deferred.await(execution.done))
}
const next = makeEntry()
active.set(key, next)
start(key, next, true)
return restore(Deferred.await(next.done))
return restore(Deferred.await(start(key, true).done))
})
const wake = (key: Key) =>
Effect.sync(() => {
const entry = active.get(key)
if (entry !== undefined) {
entry.pendingWake = true
const execution = executions.get(key)
if (execution !== undefined) {
execution.pendingWake = true
return
}
const next = makeEntry()
active.set(key, next)
start(key, next, false)
start(key, false)
})
const interrupt = (key: Key): Effect.Effect<void> =>
Effect.suspend(() => {
const entry = active.get(key)
if (entry?.owner === undefined) return Effect.void
entry.stopping = true
entry.pendingWake = false
return Fiber.interrupt(entry.owner)
const execution = executions.get(key)
if (execution?.owner === undefined) return Effect.void
execution.stopping = true
execution.pendingWake = false
return Fiber.interrupt(execution.owner)
})
// Each successful drain reuses its entry.done across coalesced wakes, so one await
// already spans steered and queued continuation. Re-check after it settles to cover a
// fresh wake (or a failure/stopping successor) that installs a new entry.
// One execution's `done` already spans coalesced continuations; re-check after it
// settles to cover a successor execution started by a late doorbell.
const awaitIdle = (key: Key): Effect.Effect<void> =>
Effect.suspend(() => {
const entry = active.get(key)
if (entry === undefined) return Effect.void
return Deferred.await(entry.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
const execution = executions.get(key)
if (execution === undefined) return Effect.void
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
})
+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
+128 -86
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,14 @@ import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { QuestionV2 } from "../../question"
import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry"
import { SystemContextBuiltIns } from "../../system-context/builtins"
import { InstructionContext } from "../../instruction-context"
import { SkillGuidance } from "../../skill/guidance"
import { ReferenceGuidance } from "../../reference/guidance"
import { McpGuidance } from "../../mcp/guidance"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextEpoch } from "../context-epoch"
import { SessionContextCheckpoint } from "../context-checkpoint"
import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
@@ -32,7 +33,7 @@ import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { type RunError, Service } from "./index"
import { Service } from "./index"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
@@ -102,7 +103,8 @@ const layer = Layer.effect(
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
const systemContext = yield* SystemContextRegistry.Service
const builtins = yield* SystemContextBuiltIns.Service
const instructions = yield* InstructionContext.Service
const skillGuidance = yield* SkillGuidance.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const mcpGuidance = yield* McpGuidance.Service
@@ -153,26 +155,17 @@ const layer = Layer.effect(
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
type TurnTransition =
// Automatic compaction completed; rebuild the request from compacted history.
| { readonly _tag: "ContinueAfterCompaction"; readonly step: number }
// Overflow compaction completed; rebuild once through the path without overflow recovery.
| { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number }
class TurnTransitionError extends Error {
constructor(readonly transition: TurnTransition) {
super()
}
}
const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
const continueAfterOverflowCompaction = (step: number) =>
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], {
concurrency: "unbounded",
}).pipe(Effect.map(SystemContext.combine))
Effect.all(
[
builtins.load(),
instructions.load(),
skillGuidance.load(agent),
referenceGuidance.load(),
mcpGuidance.load(agent),
],
{ concurrency: "unbounded" },
).pipe(Effect.map(SystemContext.combine))
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
@@ -184,24 +177,23 @@ const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
const agent = yield* agents.select(session.agent)
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
// Establish what the model knows before admitting what the user said, so
// a blocked first turn leaves pending inputs untouched.
const checkpoint = yield* SessionContextCheckpoint.prepare(db, events, loadSystemContext(agent), session.id)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false
let currentStep = step
if (promotion) {
const cutoff = yield* EventV2.latestSequence(db, session.id)
let promoted = 0
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id)
if (promotion === "queue") {
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
promoted += yield* SessionInput.promoteSteers(db, events, session.id)
}
if (promoted > 0) currentStep = 1
}
const system =
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const context = entries.map((entry) => entry.message)
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = isLastStep
@@ -211,15 +203,19 @@ const layer = Layer.effect(
const request = LLM.request({
model,
providerOptions: { openai: { promptCacheKey } },
system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline]
system: [
agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model),
checkpoint.baseline,
]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
})
// Automatic compaction completed; rebuild the request from compacted history.
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
return yield* Effect.die(continueAfterCompaction(currentStep))
return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
@@ -284,44 +280,71 @@ const layer = Layer.effect(
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
// Gather the evidence: how did the provider stream end?
const stream = yield* restore(providerStream).pipe(Effect.exit)
const failure =
stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
// away non-interrupt failures, so both interrupt checks stay Cause-based.
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
// A context overflow before any assistant output is recoverable: compact and
// restart the turn instead of surfacing the provider error.
if (
recoverOverflow &&
!publisher.hasAssistantStarted() &&
isContextOverflowFailure(overflowFailure ?? failure) &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
)
return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
// An unrecovered held-back overflow becomes the turn's durable provider error. A
// thrown LLM failure fails hosted tool calls and the assistant unless a provider
// error was already recorded from the stream.
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = failure instanceof LLMError ? failure : undefined
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* publication.withPermit(publisher.failAssistant(llmFailure.reason.message))
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError()
// Settle tool fibers: an interrupted stream abandons unstarted tool work first.
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause)
if (questionDismissed || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
return yield* Effect.interrupt
// Match V1: dismissing a question halts the loop like an interruption.
if (questionDismissed) return yield* Effect.interrupt
}
if (streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
// A settled tool fiber failure is one of two things. A defect from a tool
// implementation becomes a failed tool call the model can read, and the turn still
// settles so the model may recover. A typed infrastructure failure (tool output
// could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
const infraError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure)
const message = failure instanceof Error ? failure.message : String(failure)
yield* publication.withPermit(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
if (infraError !== undefined)
yield* publication.withPermit(publisher.failAssistant(`Tool execution failed: ${message}`))
}
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
if (
stepSettlement &&
!streamInterrupted &&
!toolsInterrupted &&
infraError === undefined &&
!providerFailed
) {
const endSnapshot = yield* snapshots.capture()
const files =
startSnapshot && endSnapshot
@@ -342,52 +365,46 @@ const layer = Layer.effect(
}),
)
}
if (publisher.hasProviderError())
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())
// A provider error orphans recorded local calls; a clean stream can still leave
// hosted calls without results.
if (providerFailed) yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !providerFailed)
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
return yield* Effect.failCause(settled.cause)
return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
return {
_tag: "Completed",
needsContinuation: !providerFailed && needsContinuation,
step: currentStep,
} as const
}),
)
}, Effect.scoped)
type RunTurn = (
const runTurn = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
step: number,
) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError>
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
return yield* runTurnAttempt(sessionID, promotion, step).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
}),
),
)
) {
// Compaction restarts rebuild the request from compacted history without re-promoting.
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
// overflow, so the recovery hook is dropped after it fires.
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
let currentPromotion = promotion
let currentStep = step
while (true) {
const attempt = yield* runTurnAttempt(sessionID, currentPromotion, currentStep, recoverOverflow)
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
yield* Effect.yieldNow
currentPromotion = undefined
currentStep = attempt.step
}
})
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
return yield* runTurn(sessionID, undefined, defect.transition.step)
}),
),
)
})
const run = Effect.fn("SessionRunner.run")(function* (input: {
const drain = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
}) {
@@ -418,6 +435,30 @@ const layer = Layer.effect(
}
})
const run = Effect.fn("SessionRunner.run")(
(input: { readonly sessionID: SessionSchema.ID; readonly force: boolean }) =>
drain(input).pipe(
Effect.onExit((exit) =>
Effect.gen(function* () {
const failure =
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
yield* events.publish(SessionEvent.ExecutionSettled, {
sessionID: input.sessionID,
timestamp: yield* DateTime.now,
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
error:
failure !== undefined
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
: undefined,
})
}).pipe(
Effect.catchCause(() => Effect.void),
Effect.asVoid,
),
),
),
)
return Service.of({
run,
})
@@ -435,7 +476,8 @@ export const node = makeLocationNode({
SessionRunnerModel.node,
SessionStore.node,
Location.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node,
InstructionContext.node,
SkillGuidance.node,
ReferenceGuidance.node,
McpGuidance.node,
+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":
+2 -2
View File
@@ -165,12 +165,12 @@ export const SessionInputTable = sqliteTable(
],
)
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
export const SessionContextCheckpointTable = sqliteTable("session_context_epoch", {
session_id: text()
.$type<SessionSchema.ID>()
.primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }),
baseline: text().notNull(),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Applied>(),
baseline_seq: integer().notNull(),
})
-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: [] })
+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: [] })
+45 -7
View File
@@ -1,12 +1,16 @@
export * as ReadTool from "./read"
import { dirname } from "path"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Image } from "../image"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { SessionInstructions } from "../session/instructions"
import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem"
import { ToolRegistry } from "./registry"
@@ -14,6 +18,7 @@ import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "read"
const FILENAME = "AGENTS.md"
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
const LocationInput = Schema.Struct({
path: Schema.String,
@@ -34,6 +39,9 @@ const layer = Layer.effectDiscard(
const mutation = yield* LocationMutation.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
yield* tools
.register({
@@ -77,12 +85,33 @@ const layer = Layer.effectDiscard(
agent: context.agent,
source,
})
if (type === "directory")
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
const content = yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
// is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () {
if (target.externalDirectory !== undefined) return
const resolved = FSUtil.resolve(target.canonical)
const root = FSUtil.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by the core/instructions baseline) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
}).pipe(Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void))
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
return yield* image
.normalize(resource, { ...content, encoding: "base64" })
@@ -113,5 +142,14 @@ const layer = Layer.effectDiscard(
export const node = makeLocationNode({
name: "tool/read",
layer,
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
deps: [
ToolRegistry.node,
ReadToolFileSystem.node,
LocationMutation.node,
Image.node,
PermissionV2.node,
SessionInstructions.node,
FSUtil.node,
Location.node,
],
})
+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],
})
+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({
+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) }))))
})
})
+11 -2
View File
@@ -81,11 +81,20 @@ describe("SessionV2.compact", () => {
const events = yield* EventV2.Service
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
const prompt = Prompt.make({ text: "Please compact this session history." })
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID: created.id,
messageID,
timestamp: DateTime.makeUnsafe(0),
prompt,
delivery: "steer",
})
yield* events.publish(SessionEvent.Prompted, {
sessionID: created.id,
messageID: SessionMessage.ID.create(),
messageID,
timestamp: DateTime.makeUnsafe(0),
prompt: Prompt.make({ text: "Please compact this session history." }),
prompt,
delivery: "steer",
})
+7 -7
View File
@@ -135,7 +135,7 @@ describe("SessionV2.create", () => {
prompt: Prompt.make({ text: "First" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, parent.id)
yield* events.publish(SessionEvent.Synthetic, {
sessionID: parent.id,
messageID: SessionMessage.ID.create(),
@@ -167,9 +167,9 @@ describe("SessionV2.create", () => {
})
yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false })
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, parent.id)
yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false })
yield* SessionInput.promoteSteers(db, events, forked.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, forked.id)
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@@ -192,13 +192,13 @@ describe("SessionV2.create", () => {
prompt: Prompt.make({ text: "First" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, parent.id)
const second = yield* session.prompt({
sessionID: parent.id,
prompt: Prompt.make({ text: "Second" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, parent.id)
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
@@ -314,7 +314,7 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service
const created = yield* session.create({ location })
yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false })
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, created.id)
expect(
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
@@ -336,7 +336,7 @@ describe("SessionV2.create", () => {
prompt: Prompt.make({ text: "Replay lifecycle" }),
resume: false,
})
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id)
const serialized = (yield* sourceDb
.select()
.from(EventTable)
@@ -0,0 +1,301 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { DateTime, Effect, Layer } from "effect"
import { Message, Model } from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Image } from "@opencode-ai/core/image"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { ModelV2 } from "@opencode-ai/core/model"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { tempLocationLayer } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { settleTool, testModel } from "./lib/tool"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: () => Effect.void,
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
const testLayer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
SessionProjector.node,
SessionStore.node,
SessionV2.node,
Location.node,
FSUtil.node,
LocationMutation.node,
ReadToolFileSystem.node,
ReadTool.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
ToolHooks.node,
SessionInstructions.node,
Global.node,
ToolOutputStore.node,
Image.node,
]),
[
[ProjectV2.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
[Location.node, tempLocationLayer],
[PermissionV2.node, permission],
[Config.node, config],
[Image.node, imageLayer],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
) as unknown as Layer.Layer<unknown>
const it = testEffect(testLayer)
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_nearby"),
}
const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({
sessionID,
...identity,
call: { type: "tool-call", id, name: "read", input: { path: readPath } },
})
const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content))
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const synthetics = (sessionID: SessionV2.ID) =>
Effect.gen(function* () {
const store = yield* SessionStore.Service
return (yield* store.context(sessionID)).filter((message) => message.type === "synthetic")
})
// Seed a prior synthetic message with an instruction dedup ledger, simulating a prior turn
// after the Location layer was reopened (in-memory set empty).
const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.Synthetic, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: `Instructions from: ${paths[0]}\nprior`,
description: `Loaded ${paths[0]}`,
metadata: { instruction: { paths } },
})
})
describe("SessionInstructions", () => {
it.effect("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const rootPath = path.resolve(dir, "AGENTS.md")
const subPath = path.resolve(dir, "sub", "AGENTS.md")
const deepPath = path.resolve(dir, "sub", "deep", "AGENTS.md")
const otherPath = path.resolve(dir, "sub", "other", "AGENTS.md")
yield* mkdir(path.dirname(deepPath))
yield* mkdir(path.dirname(otherPath))
yield* writeAgents(rootPath, "root-instructions")
yield* writeAgents(subPath, "sub-instructions")
yield* writeAgents(deepPath, "deep-instructions")
yield* writeAgents(otherPath, "other-instructions")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content"))
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2"))
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
// excluding the Location root (already supplied by the core/instructions baseline).
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
const firstInjected = yield* synthetics(sessionID)
expect(firstInjected).toHaveLength(1)
expect(firstInjected[0]!.text).toBe(
`Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`,
)
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`)
// The synthetic's metadata carries the durable dedup ledger.
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
expect(firstInjected[0]!.text).not.toContain("root-instructions")
// A sibling read under sub/other discovers only the new AGENTS.md; sub is already
// injected for this session so it is not re-emitted, and the root is still excluded.
yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
const secondInjected = yield* synthetics(sessionID)
expect(secondInjected).toHaveLength(2)
expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`)
expect(secondInjected[1]!.description).toBe(`Loaded ${path.relative(dir, otherPath)}`)
expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } })
expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false)
}),
)
it.effect("does not re-inject paths already recorded in durable session history", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const rootPath = path.resolve(dir, "AGENTS.md")
const subPath = path.resolve(dir, "sub", "AGENTS.md")
yield* mkdir(path.resolve(dir, "sub"))
yield* writeAgents(rootPath, "root-instructions")
yield* writeAgents(subPath, "sub-instructions")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
// via the instruction metadata ledger.
yield* seedSynthetic(sessionID, [subPath])
expect((yield* synthetics(sessionID))).toHaveLength(1)
yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
// The durable claim on the prior synthetic prevents re-injection; no new synthetic.
expect((yield* synthetics(sessionID))).toHaveLength(1)
}),
)
it.effect("discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const rootPath = path.resolve(dir, "AGENTS.md")
const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
yield* mkdir(path.resolve(dir, "packages", "foo"))
yield* writeAgents(rootPath, "root-instructions")
yield* writeAgents(pkgPath, "pkg-instructions")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
// the Location root (already supplied by the core/instructions baseline).
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
const firstInjected = yield* synthetics(sessionID)
expect(firstInjected).toHaveLength(1)
expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
expect(firstInjected[0]!.text).not.toContain("root-instructions")
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
// already injected for this session, so nothing new is emitted.
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
expect((yield* synthetics(sessionID))).toHaveLength(1)
}),
)
it.effect("listing the Location root directory injects no instructions", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const rootPath = path.resolve(dir, "AGENTS.md")
const subPath = path.resolve(dir, "sub", "AGENTS.md")
yield* mkdir(path.resolve(dir, "sub"))
yield* writeAgents(rootPath, "root-instructions")
yield* writeAgents(subPath, "sub-instructions")
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// The walk starts and stops at the Location root: the root AGENTS.md is searched but
// dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
expect((yield* synthetics(sessionID))).toHaveLength(0)
}),
)
it.effect("loads instructions directly without a read", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const subPath = path.resolve(dir, "sub", "AGENTS.md")
yield* mkdir(path.resolve(dir, "sub"))
yield* writeAgents(subPath, "sub-instructions")
const session = yield* SessionV2.Service
const sessionInstructions = yield* SessionInstructions.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
yield* sessionInstructions.load({ sessionID, paths: [subPath] })
const injected = yield* synthetics(sessionID)
expect(injected).toHaveLength(1)
expect(injected[0]!.text).toBe(`Instructions from: ${subPath}\nsub-instructions`)
expect(injected[0]!.description).toBe(`Loaded ${path.relative(dir, subPath)}`)
expect(injected[0]!.metadata).toEqual({ instruction: { paths: [subPath] } })
}),
)
test("toLLMMessages does not forward synthetic metadata to the provider", () => {
const created = DateTime.makeUnsafe(0)
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
const synthetic = SessionMessage.Synthetic.make({
id: SessionMessage.ID.make("msg_synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_test"),
text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
description: "Loaded /repo/sub/AGENTS.md",
metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },
time: { created },
})
const messages = toLLMMessages([synthetic], model)
expect(messages).toHaveLength(1)
expect(messages[0]!.role).toBe("user")
expect(messages[0]!.content).toEqual([{ type: "text", text: "Instructions from: /repo/sub/AGENTS.md\ncontent" }])
// Metadata is bookkeeping for the dedup ledger; the model must not see it.
expect(messages[0]!.metadata).toBeUndefined()
})
})
+31 -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()
}),
)
@@ -118,6 +129,13 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID,
messageID: SessionMessage.ID.make("msg_first"),
timestamp: created,
prompt: Prompt.make({ text: "first" }),
delivery: "steer",
})
yield* events.publish(
SessionEvent.Prompted,
{
@@ -129,6 +147,13 @@ describe("SessionProjector", () => {
},
{ id: EventV2.ID.make("evt_z") },
)
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID,
messageID: SessionMessage.ID.make("msg_second"),
timestamp: created,
prompt: Prompt.make({ text: "second" }),
delivery: "steer",
})
yield* events.publish(
SessionEvent.Prompted,
{
@@ -248,6 +273,7 @@ describe("SessionProjector", () => {
messageID: SessionMessage.ID.create(),
timestamp: created,
text: "synthetic context",
metadata: { source: "projector-test" },
})
yield* events.publish(SessionEvent.Shell.Started, {
sessionID,
@@ -318,6 +344,10 @@ describe("SessionProjector", () => {
"shell",
"compaction",
])
expect(messages.find((message) => message.type === "synthetic")).toMatchObject({
text: "synthetic context",
metadata: { source: "projector-test" },
})
expect(messages.find((message) => message.type === "shell")).toMatchObject({
output: "/project",
time: { completed: DateTime.makeUnsafe(1) },
+3 -65
View File
@@ -197,7 +197,7 @@ describe("SessionV2.prompt", () => {
prompt: Prompt.make({ text: "boundary" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, sessionID)
const stale = SessionMessage.ID.make("msg_stale_assistant")
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* events.publish(SessionEvent.RevertEvent.Staged, {
@@ -250,7 +250,7 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, sessionID)
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
@@ -424,10 +424,7 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false })
yield* Effect.all(
[
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
],
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
{ concurrency: "unbounded" },
)
@@ -439,23 +436,6 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("promotes steers only through the captured inbox cutoff", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const first = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Before cutoff" }), resume: false })
const cutoff = first.admittedSeq
const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
expect(yield* admitted(first.id)).toHaveProperty("promotedSeq")
expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq")
}),
)
it.effect("reprojects pending inbox input without scheduling execution", () =>
Effect.gen(function* () {
yield* setup
@@ -499,48 +479,6 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("returns an exact retry of a legacy projected prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = Prompt.make({ text: "Historical prompt" })
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID,
timestamp: yield* DateTime.now,
prompt,
delivery: "steer",
})
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical prompt" } })
expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
}),
)
it.effect("returns an exact retry of a legacy projected queued prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = Prompt.make({ text: "Historical queued prompt" })
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID,
timestamp: yield* DateTime.now,
prompt,
delivery: "queue",
})
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical queued prompt" } })
expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
}),
)
it.effect("rejects reuse of one globally unique message ID across sessions", () =>
Effect.gen(function* () {
yield* setup
+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],
+150 -61
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,15 @@ import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { Tool } from "@opencode-ai/core/tool/tool"
import {
SessionContextEpochTable,
SessionContextCheckpointTable,
SessionInputTable,
SessionMessageTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
import { InstructionContext } from "@opencode-ai/core/instruction-context"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
@@ -154,6 +154,14 @@ const echo = Layer.effectDiscard(
output: Schema.Struct({}),
execute: () => Effect.die("unexpected tool defect"),
}),
// BigInt output with no model content forces ToolOutputStore.bound onto its
// JSON.stringify encode path, which fails with a typed StorageError.
storefail: Tool.make({
description: "Produce output that cannot be persisted",
input: Schema.Struct({}),
output: Schema.Any,
execute: () => Effect.succeed({ big: 1n }),
}),
}),
),
)
@@ -169,35 +177,28 @@ let systemRemoved = false
let systemUnavailable = false
let systemLoadHook = Effect.void
const skillBaselines = new Map<AgentV2.ID, string>()
const systemContext = Layer.effectDiscard(
SystemContextRegistry.Service.pipe(
Effect.flatMap((registry) =>
registry.register({
key: systemContextKey,
load: Effect.sync(() =>
SystemContext.combine(
systemRemoved
? []
: [
SystemContext.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: systemLoadHook.pipe(
Effect.andThen(
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
),
),
baseline: String,
update: (_previous, current) => current,
removed: () => "System context source removed: test/context",
}),
],
),
),
}),
const systemContext = Layer.mock(SystemContextBuiltIns.Service, {
load: () =>
Effect.sync(() =>
SystemContext.combine(
systemRemoved
? []
: [
SystemContext.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: systemLoadHook.pipe(
Effect.andThen(Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline))),
),
baseline: String,
update: (_previous, current) => current,
removed: () => "System context source removed: test/context",
}),
],
),
),
),
).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node)))
})
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
const skillGuidance = Layer.mock(SkillGuidance.Service, {
load: (agent) =>
Effect.succeed(
@@ -236,7 +237,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[SystemContextRegistry.node, systemContext],
[SystemContextBuiltIns.node, systemContext],
[InstructionContext.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
@@ -274,7 +276,8 @@ const it = testEffect(
ToolRegistry.toolsNode,
echoNode,
SessionRunnerModel.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node,
InstructionContext.node,
SkillGuidance.node,
ReferenceGuidance.node,
Config.node,
@@ -287,7 +290,8 @@ const it = testEffect(
[LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission],
[SessionRunnerModel.node, models],
[SystemContextRegistry.node, systemContext],
[SystemContextBuiltIns.node, systemContext],
[InstructionContext.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
@@ -647,6 +651,7 @@ describe("SessionRunnerLLM", () => {
response = []
const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) })
yield* session.wait(sessionID)
expect(requests).toHaveLength(1)
expect(yield* session.messages({ sessionID })).toMatchObject([
@@ -671,7 +676,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
expect(requests[0]?.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
{ role: "user", content: [{ type: "text", text: "First" }] },
{ role: "user", content: [{ type: "text", text: "Second" }] },
@@ -699,13 +704,14 @@ describe("SessionRunnerLLM", () => {
expect(
yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.get(),
).toBeUndefined()
systemUnavailable = false
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) })
yield* session.wait(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
@@ -731,8 +737,8 @@ describe("SessionRunnerLLM", () => {
expect(
yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.get(),
).toBeUndefined()
@@ -745,7 +751,36 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("fails gracefully when a stored context snapshot cannot be decoded", () =>
it.effect("copies the context checkpoint to a fork", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
const forked = yield* session.fork({ sessionID })
const parent = yield* db
.select()
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
expect(parent).toBeDefined()
expect(
yield* db
.select()
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, forked.id))
.get()
.pipe(Effect.orDie),
).toEqual({ ...parent!, session_id: forked.id })
}),
)
it.effect("heals an undecodable stored applied record by re-announcing context", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -754,19 +789,28 @@ describe("SessionRunnerLLM", () => {
response = []
yield* session.resume(sessionID)
yield* db
.update(SessionContextEpochTable)
.update(SessionContextCheckpointTable)
.set({ snapshot: { invalid: { value: "bad" } } })
.where(eq(SessionContextEpochTable.session_id, sessionID))
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
requests.length = 0
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
yield* session.resume(sessionID)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(ContextSnapshotDecodeError)
expect(requests).toHaveLength(0)
// Comparison state was lost, so every source re-announces as new.
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[0]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Initial context" }])
const healed = yield* db
.select({ snapshot: SessionContextCheckpointTable.snapshot })
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
expect(healed?.snapshot).toEqual({ "test/context": { value: "Initial context", removed: expect.any(String) } })
}),
)
@@ -787,8 +831,8 @@ describe("SessionRunnerLLM", () => {
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
expect(yield* session.messages({ sessionID })).toHaveLength(3)
const { db } = yield* Database.Service
expect(
@@ -1049,8 +1093,8 @@ describe("SessionRunnerLLM", () => {
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[1]?.messages.at(-1)?.content).toEqual([
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.at(1)?.content).toEqual([
{ type: "text", text: "System context source removed: test/context" },
])
expect(yield* session.messages({ sessionID })).toHaveLength(3)
@@ -1085,15 +1129,15 @@ describe("SessionRunnerLLM", () => {
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
"user",
"user",
"system",
"user",
"model-switched",
"user",
"system",
"user",
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(6)
@@ -1361,7 +1405,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
it.effect("rebaselines after compaction from the last-applied belief while unobservable", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -1393,8 +1437,9 @@ describe("SessionRunnerLLM", () => {
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
// The rebaseline proceeds while the source is unobservable, restating the model's belief.
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
expect(systemTexts(requests.at(-1)!)).not.toContain("Changed context")
}),
)
@@ -1456,7 +1501,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use tools" },
{
@@ -2292,7 +2337,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false })
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
@@ -2356,7 +2401,7 @@ describe("SessionRunnerLLM", () => {
prompt: Prompt.make({ text: "Recover interrupted hosted tool" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
@@ -2416,7 +2461,7 @@ describe("SessionRunnerLLM", () => {
prompt: Prompt.make({ text: "Recover interrupted tool input" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
@@ -2529,8 +2574,9 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
streamStarted = yield* Deferred.make<void>()
const second = yield* session.resume(otherSessionID).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.await(streamStarted)
expect(requests).toHaveLength(2)
expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([
@@ -2700,6 +2746,49 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("fails the drain when tool output persistence fails", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call storefail" }), resume: false })
requests.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-storefail", name: "storefail", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call storefail" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-storefail",
state: {
status: "error",
error: {
type: "unknown",
message: expect.stringContaining("Tool execution failed: Failed to encode tool output"),
},
},
},
],
},
])
}),
)
it.effect("interrupts runner continuation when a question is dismissed", () =>
Effect.gen(function* () {
yield* setup
+29 -14
View File
@@ -39,7 +39,14 @@ const client = Layer.mock(LLMClient.Service)({
const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) })
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, AgentV2.node, SessionTitle.node]),
LayerNode.group([
Database.node,
EventV2.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
SessionTitle.node,
]),
[
[llmClient, client],
[SessionRunnerModel.node, models],
@@ -74,9 +81,17 @@ const insertSession = (id: SessionV2.ID) =>
const prompt = (sessionID: SessionV2.ID, text: string) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const messageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID,
messageID,
timestamp: DateTime.makeUnsafe(0),
prompt: Prompt.make({ text }),
delivery: "steer",
})
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID: SessionMessage.ID.create(),
messageID,
timestamp: DateTime.makeUnsafe(0),
prompt: Prompt.make({ text }),
delivery: "steer",
@@ -99,9 +114,9 @@ it.effect("generates a title from the sole user message and renames the session"
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store.get(sessionID).pipe(
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
)
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
@@ -129,9 +144,9 @@ it.effect("does not generate once a second user message exists", () =>
yield* prompt(sessionID, "Second message")
const store = yield* SessionStore.Service
const session = yield* store.get(sessionID).pipe(
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
)
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
@@ -177,9 +192,9 @@ it.effect("does not generate for a child session", () =>
yield* prompt(sessionID, "Do this subtask")
const store = yield* SessionStore.Service
const session = yield* store.get(sessionID).pipe(
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
)
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
@@ -195,9 +210,9 @@ it.effect("does not generate when the title agent is removed", () =>
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store.get(sessionID).pipe(
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
)
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
+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: {} })
}),
)
})
@@ -148,9 +148,22 @@ const AnthropicToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
])
const AnthropicThinking = Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
}),
Schema.Struct({
type: Schema.tag("adaptive"),
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
}),
Schema.Struct({
type: Schema.tag("disabled"),
}),
])
const AnthropicOutputConfig = Schema.Struct({
effort: Schema.optional(Schema.String),
})
const AnthropicBodyFields = {
@@ -166,6 +179,7 @@ const AnthropicBodyFields = {
top_k: Schema.optional(Schema.Number),
stop_sequences: optionalArray(Schema.String),
thinking: Schema.optional(AnthropicThinking),
output_config: Schema.optional(AnthropicOutputConfig),
}
const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -492,7 +506,18 @@ const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthr
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
const thinking = anthropicOptions(request)?.thinking
if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined
if (!ProviderShared.isRecord(thinking)) return undefined
if (thinking.type === "adaptive") {
const display =
thinking.display === "summarized"
? ("summarized" as const)
: thinking.display === "omitted"
? ("omitted" as const)
: undefined
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
}
if (thinking.type === "disabled") return { type: "disabled" as const }
if (thinking.type !== "enabled") return undefined
const budget =
typeof thinking.budgetTokens === "number"
? thinking.budgetTokens
@@ -503,6 +528,11 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
return { type: "enabled" as const, budget_tokens: budget }
})
const outputConfig = (request: LLMRequest) => {
const effort = anthropicOptions(request)?.effort
return typeof effort === "string" ? { effort } : undefined
}
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation
@@ -549,6 +579,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: yield* lowerThinking(request),
output_config: outputConfig(request),
}
})
@@ -168,8 +168,6 @@ interface ParserState {
readonly lifecycle: Lifecycle.State
}
const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
@@ -333,8 +331,6 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
const store = OpenAIOptions.store(request)
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
return {
...(store !== undefined ? { store } : {}),
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
@@ -457,8 +457,6 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
const store = OpenAIOptions.store(request)
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
const effort = OpenAIOptions.reasoningEffort(request)
if (effort && !OpenAIOptions.isReasoningEffort(effort))
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
const summary = OpenAIOptions.reasoningSummary(request)
const include = OpenAIOptions.include(request)
const verbosity = OpenAIOptions.textVerbosity(request)
@@ -1,11 +1,9 @@
import { Schema } from "effect"
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema"
import { ReasoningEfforts, TextVerbosity } from "../../schema"
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
)
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
export const OpenAIReasoningEfforts = ReasoningEfforts
export type OpenAIReasoningEffort = string
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
@@ -23,22 +21,16 @@ export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number
export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
export const OpenAIReasoningEffort = Schema.String
export const OpenAITextVerbosity = TextVerbosity
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
typeof effort === "string" && REASONING_EFFORTS.has(effort)
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
@@ -50,9 +42,9 @@ export const store = (request: LLMRequest): boolean | undefined => {
return typeof value === "boolean" ? value : undefined
}
export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
export const reasoningEffort = (request: LLMRequest): string | undefined => {
const value = options(request)?.reasoningEffort
return isAnyReasoningEffort(value) ? value : undefined
return typeof value === "string" ? value : undefined
}
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
+1 -1
View File
@@ -27,7 +27,7 @@ export const ToolCallID = Schema.String
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
export const ReasoningEffort = Schema.String
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
export const TextVerbosity = Schema.Literals(["low", "medium", "high"])
@@ -57,6 +57,23 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.updateRequest(request, {
providerOptions: {
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
}),
)
expect(prepared.body).toMatchObject({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
})
}),
)
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
+16 -2
View File
@@ -98,12 +98,26 @@ describe("OpenAI Chat route", () => {
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
providerOptions: { openai: { reasoningEffort: "low" } },
providerOptions: { openai: { reasoningEffort: "max" } },
}),
)
expect(prepared.body.store).toBe(false)
expect(prepared.body.reasoning_effort).toBe("low")
expect(prepared.body.reasoning_effort).toBe("max")
}),
)
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
prompt: "think",
providerOptions: { openai: { reasoningEffort: "experimental" } },
}),
)
expect(prepared.body.reasoning_effort).toBe("experimental")
}),
)
@@ -69,6 +69,16 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.updateRequest(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
)
expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
}),
)
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
-50
View File
@@ -43,32 +43,8 @@ export const AttachCommand = cmd({
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
})
.option("mini", {
type: "boolean",
describe: "start the minimal interactive interface",
default: false,
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable mini session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible mini replay to the newest N messages",
}),
handler: async (args) => {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return
}
const noReplay = args.replay === false || args.noReplay === true
const directory = (() => {
if (!args.dir) return undefined
try {
@@ -80,32 +56,6 @@ export const AttachCommand = cmd({
}
})()
if (args.mini) {
const { runMini } = await import("./run")
await runMini({
attach: args.url,
directory,
password: args.password,
username: args.username,
continue: args.continue,
session: args.session,
fork: args.fork,
replay: noReplay ? false : undefined,
replayLimit: args.replayLimit,
})
return
}
const unsupported = [
["--no-replay", noReplay],
["--replay-limit", args.replayLimit !== undefined],
].find((entry) => entry[1])?.[0]
if (unsupported) {
UI.error(`${unsupported} requires --mini`)
process.exitCode = 1
return
}
const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
+172
View File
@@ -0,0 +1,172 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { UI } from "@/cli/ui"
import { resolveThreadDirectory } from "./tui"
type ReplayArgs = {
replay?: boolean
noReplay?: boolean
}
type MiniArgs = ReplayArgs & {
continue?: boolean
session?: string
fork?: boolean
replayLimit?: number
}
type MiniLocalArgs = MiniArgs & {
project?: string
model?: string
agent?: string
prompt?: string
demo?: boolean
}
type MiniAttachArgs = MiniArgs & {
url: string
dir?: string
password?: string
username?: string
}
function replay(args: ReplayArgs) {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return "invalid" as const
}
return args.replay === false || args.noReplay === true ? false : undefined
}
function miniOptions<T>(yargs: Argv<T>) {
return yargs
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
describe: "session id to continue",
type: "string",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible replay to the newest N messages",
})
}
/** @internal Exported for CLI parser tests. */
export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({
command: "$0 [project]",
describe: "start the minimal interactive interface",
builder: (yargs) =>
miniOptions(
yargs
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
})
.option("demo", {
type: "boolean",
hidden: true,
}),
),
handler: async (args) => {
const shouldReplay = replay(args)
if (shouldReplay === "invalid") return
const { runMini } = await import("./run")
await runMini({
directory: resolveThreadDirectory(args.project),
continue: args.continue,
session: args.session,
fork: args.fork,
model: args.model,
agent: args.agent,
prompt: args.prompt,
replay: shouldReplay,
replayLimit: args.replayLimit,
demo: args.demo,
})
},
})
/** @internal Exported for CLI parser tests. */
export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({
command: "attach <url>",
describe: "attach to a running opencode server with the minimal interface",
builder: (yargs) =>
miniOptions(
yargs
.positional("url", {
type: "string",
describe: "http://localhost:4096",
demandOption: true,
})
.option("dir", {
type: "string",
describe: "directory on the remote server",
})
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
}),
),
handler: async (args) => {
const shouldReplay = replay(args)
if (shouldReplay === "invalid") return
const { runMini } = await import("./run")
await runMini({
attach: args.url,
directory: args.dir,
password: args.password,
username: args.username,
continue: args.continue,
session: args.session,
fork: args.fork,
replay: shouldReplay,
replayLimit: args.replayLimit,
})
},
})
export const MiniCommand = cmd({
command: "mini",
describe: "start the minimal interactive interface",
builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(),
handler: async () => {},
})
+262 -129
View File
@@ -1,13 +1,13 @@
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { FSUtil } from "@opencode-ai/core/fs-util"
// CLI entry point for `opencode run` and `opencode --mini`.
// CLI entry point for `opencode run` and `opencode mini`.
//
// Handles three modes:
// 1. Non-interactive (default): sends a single prompt, streams events to
// stdout, and exits when the session goes idle.
// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode
// 2. Interactive local (`opencode mini`): boots the split-footer direct mode
// with an in-process server (no external HTTP).
// 3. Interactive attach (`opencode --mini --attach`): connects to a running
// 3. Interactive attach (`opencode mini attach`): connects to a running
// opencode server and runs interactive mode against it.
//
// Also supports `--command` for slash-command execution, `--format json` for
@@ -25,6 +25,8 @@ import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { FormatError, FormatUnknownError } from "../error"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
import { isImageAttachment, isPdfAttachment } from "@/util/media"
import { loadRunAgents } from "./run/catalog.shared"
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
@@ -49,6 +51,14 @@ function resolveRunInput(value?: string, piped?: string): string | undefined {
return value + "\n" + piped
}
function isBinaryContent(bytes: Uint8Array) {
if (bytes.length === 0) return false
if (bytes.includes(0)) return true
return (
bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
)
}
type FilePart = {
type: "file"
url: string
@@ -68,6 +78,7 @@ type SessionInfo = {
id: string
title?: string
directory?: string
current?: boolean
}
function inline(info: Inline) {
@@ -158,10 +169,6 @@ export const RunCommand = effectCmd({
describe: "fork the session before continuing (requires --continue or --session)",
type: "boolean",
})
.option("share", {
type: "boolean",
describe: "share the session",
})
.option("model", {
type: "string",
alias: ["m"],
@@ -217,11 +224,6 @@ export const RunCommand = effectCmd({
type: "boolean",
describe: "show thinking blocks",
})
.option("mini", {
type: "boolean",
hidden: true,
default: false,
})
.option("replay", {
type: "boolean",
default: true,
@@ -270,7 +272,7 @@ export const RunCommand = effectCmd({
const localInstance = yield* InstanceRef
yield* Effect.promise(async () => {
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
const interactive = args.mini
const interactive = (args as typeof args & { mini?: boolean }).mini === true
const auto = args.auto || args.yolo || args["dangerously-skip-permissions"]
const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
const die = (message: string): never => {
@@ -290,23 +292,23 @@ export const RunCommand = effectCmd({
.join(" ")
if (interactive && args.command) {
die("--mini cannot be used with --command")
die("opencode mini cannot be used with --command")
}
if (interactive && args._?.[0] !== "mini") {
die("--mini must be used without the run subcommand")
die("opencode mini must be run with the mini command")
}
if (args.demo && !interactive) {
die("--demo requires --mini")
die("--demo requires opencode mini")
}
if (interactive && args.format === "json") {
die("--mini cannot be used with --format json")
die("opencode mini cannot be used with --format json")
}
if (args["replay-limit"] !== undefined && !interactive) {
die("--replay-limit requires --mini")
die("--replay-limit requires opencode mini")
}
if (
@@ -317,7 +319,7 @@ export const RunCommand = effectCmd({
}
if (interactive && !process.stdout.isTTY) {
die("--mini requires a TTY stdout")
die("opencode mini requires a TTY stdout")
}
if (interactive) {
@@ -355,6 +357,12 @@ export const RunCommand = effectCmd({
}
const files: FilePart[] = []
const fileInputs: Array<{
filePath: string
resolvedPath: string
stat: ReturnType<typeof Filesystem.stat>
isDirectory: boolean
}> = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
@@ -371,45 +379,7 @@ export const RunCommand = effectCmd({
UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`)
process.exit(1)
}
const content = await (async () => {
if (!args.attach) return
const handle = await open(resolvedPath, "r")
try {
const opened = await handle.stat()
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`)
process.exit(1)
}
if (opened.size === 0) return Buffer.alloc(0)
const buffer = Buffer.alloc(Number(opened.size))
let offset = 0
while (offset < buffer.length) {
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
if (read.bytesRead === 0) break
offset += read.bytesRead
}
return buffer.subarray(0, offset)
} finally {
await handle.close()
}
})()
const detected = FSUtil.mimeType(resolvedPath)
const text = content?.toString("utf8")
const mime = !args.attach
? isDirectory
? "application/x-directory"
: "text/plain"
: content && text !== undefined && Buffer.from(text, "utf8").equals(content)
? "text/plain"
: detected
files.push({
type: "file",
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})
fileInputs.push({ filePath, resolvedPath, stat, isDirectory })
}
}
@@ -446,6 +416,53 @@ export const RunCommand = effectCmd({
pattern: "*",
},
]
const currentPrompt = !interactive && !args.command && fileInputs.every((file) => !file.isDirectory)
const inlineFiles = interactive || currentPrompt
for (const file of fileInputs) {
const content = await (async () => {
if (file.isDirectory || !inlineFiles) return
if (!file.stat?.isFile() || file.stat.size > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
process.exit(1)
}
const handle = await open(file.resolvedPath, "r")
try {
const opened = await handle.stat()
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
process.exit(1)
}
if (opened.size === 0) return Buffer.alloc(0)
const buffer = Buffer.alloc(Number(opened.size))
let offset = 0
while (offset < buffer.length) {
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
if (read.bytesRead === 0) break
offset += read.bytesRead
}
return buffer.subarray(0, offset)
} finally {
await handle.close()
}
})()
const detected = FSUtil.mimeType(file.resolvedPath)
const text = content?.toString("utf8")
const mime = file.isDirectory
? "application/x-directory"
: isImageAttachment(detected) || isPdfAttachment(detected)
? detected
: content && !isBinaryContent(content) && text !== undefined && Buffer.from(text, "utf8").equals(content)
? "text/plain"
: detected
files.push({
type: "file",
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(file.resolvedPath).href,
filename: path.basename(file.resolvedPath),
mime,
})
}
function title() {
if (args.title === undefined) return
@@ -453,58 +470,122 @@ export const RunCommand = effectCmd({
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
if (args.session) {
const current = await sdk.session
.get({
sessionID: args.session,
})
.catch(() => undefined)
async function currentSession(sdk: OpencodeClient, sessionID: string): Promise<SessionInfo | undefined> {
const listed = await sdk.v2.session
.list({
directory: await current(sdk),
limit: 50,
order: "desc",
})
.then((result) => result.data?.data.find((item) => item.id === sessionID))
.catch(() => undefined)
const selected =
listed ??
(await sdk.v2.session
.get({ sessionID })
.then((result) => result.data?.data)
.catch(() => undefined))
const legacy =
selected ??
(await sdk.session
.get({ sessionID })
.then((result) => result.data)
.catch(() => undefined))
const transcript = await transcriptKind(sdk, legacy?.id ?? sessionID)
if (!legacy && transcript === "empty") {
return
}
if (interactive && transcript === "legacy") {
throw new Error("Mini cannot resume a legacy Session transcript")
}
if (!current?.data) {
UI.error("Session not found")
process.exit(1)
}
if (args.fork) {
const forked = await sdk.session.fork({
sessionID: args.session,
})
const id = forked.data?.id
if (!id) {
return
}
return {
id,
title: forked.data?.title ?? current.data.title,
directory: forked.data?.directory ?? current.data.directory,
}
}
return {
id: legacy?.id ?? sessionID,
title: legacy?.title,
directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk),
current: transcript !== "legacy",
}
}
async function forkSession(sdk: OpencodeClient, session: SessionInfo): Promise<SessionInfo | undefined> {
if (session.current !== false) {
const forked = await sdk.v2.session.fork(
{ sessionID: session.id, messageID: undefined },
{ throwOnError: true },
)
await waitForFork(sdk, session.id, forked.data.data.id)
return {
id: current.data.id,
title: current.data.title,
directory: current.data.directory,
id: forked.data.data.id,
title: forked.data.data.title,
directory: forked.data.data.location.directory,
current: true,
}
}
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
const forked = await sdk.session.fork({
sessionID: session.id,
})
const id = forked.data?.id
if (!id) {
return
}
if (base && args.fork) {
const forked = await sdk.session.fork({
sessionID: base.id,
})
const id = forked.data?.id
if (!id) {
return {
id,
title: forked.data?.title ?? session.title,
directory: forked.data?.directory ?? session.directory,
current: false,
}
}
async function waitForFork(sdk: OpencodeClient, parentID: string, sessionID: string) {
const parentHasMessages = await sdk.v2.session
.messages({ sessionID: parentID, limit: 1 })
.then((result) => (result.data?.data.length ?? 0) > 0)
.catch(() => false)
if (!parentHasMessages) {
return
}
const deadline = Date.now() + 3000
while (Date.now() < deadline) {
const forkedHasMessages = await sdk.v2.session
.messages({ sessionID, limit: 1 })
.then((result) => (result.data?.data.length ?? 0) > 0)
.catch(() => false)
if (forkedHasMessages) {
return
}
return {
id,
title: forked.data?.title ?? base.title,
directory: forked.data?.directory ?? base.directory,
await Bun.sleep(25)
}
}
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
if (args.session) {
const current = await currentSession(sdk, args.session)
if (!current) {
UI.error("Session not found")
process.exit(1)
}
if (!interactive && !currentPrompt && current.current !== false) {
throw new Error("This operation is not available for a current Session transcript")
}
if (args.fork) {
return forkSession(sdk, current)
}
return current
}
const base = args.continue ? await currentRootSession(sdk) : undefined
if (base && !interactive && !currentPrompt && base.current !== false) {
throw new Error("This operation is not available for a current Session transcript")
}
if (base && args.fork) {
return forkSession(sdk, base)
}
if (base) {
@@ -512,6 +593,23 @@ export const RunCommand = effectCmd({
id: base.id,
title: base.title,
directory: base.directory,
current: "current" in base ? base.current : false,
}
}
if (interactive || currentPrompt) {
const name = title()
const result = await sdk.v2.session.create({
location: { directory: await current(sdk) },
})
const created = result.data?.data
if (!created) return
if (name) await sdk.v2.session.rename({ sessionID: created.id, title: name })
return {
id: created.id,
title: name ?? created.title,
directory: created.location.directory,
current: true,
}
}
@@ -529,30 +627,45 @@ export const RunCommand = effectCmd({
id,
title: result.data?.title ?? name,
directory: result.data?.directory,
current: false,
}
}
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
async function currentRootSession(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
const response = await sdk.v2.session.list({
directory: await current(sdk),
limit: 50,
order: "desc",
})
if (!res.error && "data" in res && res.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
const root = (response.data?.data ?? [])
.filter((session) => !session.parentID)
.toSorted((a, b) => b.time.updated - a.time.updated)[0]
if (!root) return
return currentSession(sdk, root.id)
}
async function transcriptKind(sdk: OpencodeClient, sessionID: string) {
const current = await sdk.v2.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.data.length ?? 0) > 0)
// Ordinary prompt flows assume a transcript with current messages is
// current-owned; only legacy-only modes (--command, directory
// attachments) still probe legacy history for mixed transcripts.
if (current && (interactive || currentPrompt)) return "current" as const
const legacy = await sdk.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.length ?? 0) > 0)
if (current) {
if (legacy) throw new Error("Session contains mixed legacy and current transcripts")
return "current" as const
}
if (legacy) return "legacy" as const
return "empty" as const
}
async function createFreshSession(
sdk: OpencodeClient,
input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined },
): Promise<SessionInfo> {
const result = await sdk.session.create({
title: args.title !== undefined && args.title !== "" ? args.title : undefined,
const name = args.title !== undefined && args.title !== "" ? args.title : undefined
const result = await sdk.v2.session.create({
agent: input.agent,
model: input.model
? {
@@ -561,17 +674,18 @@ export const RunCommand = effectCmd({
variant: input.variant,
}
: undefined,
permission: [...rules],
location: { directory: await current(sdk) },
})
const id = result.data?.id
const created = result.data?.data
const id = created?.id
if (!id) {
throw new Error("Failed to create session")
}
if (name) await sdk.v2.session.rename({ sessionID: id, title: name })
void share(sdk, id).catch(() => {})
return {
id,
title: result.data?.title,
title: name ?? created.title,
}
}
@@ -580,8 +694,8 @@ export const RunCommand = effectCmd({
return directory ?? root
}
const next = await sdk.path
.get()
const next = await sdk.v2.location
.get(undefined, { throwOnError: true })
.then((x) => x.data?.directory)
.catch(() => undefined)
if (next) {
@@ -622,10 +736,7 @@ export const RunCommand = effectCmd({
if (!args.agent) return undefined
const name = args.agent
const modes = await sdk.app
.agents(undefined, { throwOnError: true })
.then((x) => x.data ?? [])
.catch(() => undefined)
const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined)
if (!modes) {
UI.println(
@@ -636,7 +747,7 @@ export const RunCommand = effectCmd({
return undefined
}
const agent = modes.find((a) => a.name === name)
const agent = modes.find((item) => item.name === name)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
@@ -823,9 +934,33 @@ export const RunCommand = effectCmd({
// Validate agent if specified
const agent = await pickAgent(client)
await share(client, sessionID)
if (!interactive) {
if (currentPrompt && sess.current !== false) {
const model = pick(args.model)
const { runNonInteractivePrompt } = await import("./run/noninteractive")
try {
await runNonInteractivePrompt({
client,
sessionID,
message,
files,
agent,
model,
variant: args.variant,
thinking,
format: args.format === "json" ? "json" : "default",
dangerouslySkipPermissions: args["dangerously-skip-permissions"],
renderTool: tool,
renderToolError: toolError,
})
} catch (error) {
const output = error instanceof Error ? { type: "unknown", message: error.message } : error
if (!emit("error", { error: output })) UI.error(formatRunError(error))
process.exitCode = 1
}
return
}
const events = await client.event.subscribe()
const completed = loop(client, events).catch((e) => {
console.error(e)
@@ -880,7 +1015,7 @@ export const RunCommand = effectCmd({
directory: cwd,
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session || args.continue) && !args.fork,
resume: Boolean(args.session || args.continue),
replay,
replayLimit: args["replay-limit"],
agent,
@@ -917,7 +1052,6 @@ export const RunCommand = effectCmd({
fetch: fetchFn,
resolveAgent: localAgent,
session,
share,
createSession: createFreshSession,
agent: args.agent,
model,
@@ -984,7 +1118,6 @@ export async function runMini(input: MiniCommandInput) {
continue: input.continue,
session: input.session,
fork: input.fork,
share: undefined,
model: input.model,
agent: input.agent,
format: "default",
@@ -1007,5 +1140,5 @@ export async function runMini(input: MiniCommandInput) {
"dangerously-skip-permissions": false,
dangerouslySkipPermissions: false,
demo: input.demo ?? false,
})
} as Parameters<NonNullable<typeof RunCommand.handler>>[0] & { mini: boolean })
}
@@ -0,0 +1,114 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
type CurrentProvider = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]>["data"][number]
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
function location(directory: string) {
return {
location: {
directory,
},
}
}
function defaultCost(model: CurrentModel) {
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
if (!picked) {
return undefined
}
return {
...picked,
input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input,
}
}
export function runAgent(input: CurrentAgent): RunAgent {
return {
name: input.id,
description: input.description,
mode: input.mode,
hidden: input.hidden,
}
}
export function runCommand(input: CurrentCommand): RunCommand {
return {
name: input.name,
description: input.description,
}
}
export function runSkill(input: CurrentSkill): RunCommand {
return {
name: input.name,
description: input.description,
source: "skill",
}
}
export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] {
const grouped = new Map<string, RunProvider>()
for (const provider of providers) {
grouped.set(provider.id, {
id: provider.id,
name: provider.name,
models: {},
})
}
for (const model of models) {
const provider = grouped.get(model.providerID) ?? {
id: model.providerID,
name: model.providerID,
models: {},
}
provider.models[model.id] = {
id: model.id,
providerID: model.providerID,
name: model.name,
capabilities: model.capabilities,
cost: defaultCost(model),
limit: model.limit,
status: model.status,
variants: Object.fromEntries(model.variants.map((variant) => [variant.id, {}])),
}
grouped.set(provider.id, provider)
}
return [...grouped.values()]
}
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).map(runAgent)
}
export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise<RunCommand[]> {
const [commands, skills] = await Promise.all([
sdk.v2.command.list(location(directory), { throwOnError: true }),
sdk.v2.skill.list(location(directory), { throwOnError: true }),
])
return [
...(commands.data?.data ?? []).map(runCommand),
...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill),
]
}
export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.v2.reference.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).filter((reference) => !reference.hidden)
}
export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise<RunProvider[]> {
const [providers, models] = await Promise.all([
sdk.v2.provider.list(location(directory), { throwOnError: true }),
sdk.v2.model.list(location(directory), { throwOnError: true }),
])
return runProviders(providers.data?.data ?? [], models.data?.data ?? [])
}
@@ -141,7 +141,9 @@ export function RunPermissionBody(props: {
const info = createMemo(() => permissionInfo(props.request))
const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const opts = createMemo(() => permissionOptions(state().stage))
const opts = createMemo(() =>
permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0),
)
const busy = createMemo(() => state().submitting)
const title = createMemo(() => {
if (state().stage === "always") {
@@ -165,7 +167,7 @@ export function RunPermissionBody(props: {
})
const shift = (dir: -1 | 1) => {
setState((prev) => permissionShift(prev, dir))
setState((prev) => permissionShift(prev, dir, opts()))
}
const submit = async (next: PermissionReply) => {
@@ -1,7 +1,7 @@
// Prompt composer and its state machine for direct interactive mode.
//
// createPromptState() wires keymap command layers, history navigation, and
// `@` autocomplete for files, subagents, and MCP resources.
// `@` autocomplete for files, subagents, and project references.
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
/** @jsxImportSource @opentui/solid */
@@ -27,7 +27,7 @@ import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
@@ -59,7 +59,7 @@ type PromptInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: Accessor<RunAgent[]>
resources: Accessor<RunResource[]>
references: Accessor<RunReference[]>
commands: Accessor<RunCommand[] | undefined>
tuiConfig: RunTuiConfig
state: Accessor<FooterState>
@@ -333,21 +333,20 @@ export function createPromptState(input: PromptInput): PromptState {
},
}))
})
const resources = createMemo<Auto[]>(() => {
return input.resources().map((item) => ({
const references = createMemo<Auto[]>(() => {
return input.references().map((item) => ({
kind: "mention",
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
display: Locale.truncateMiddle("@" + item.name, width()),
value: item.name,
description: item.description,
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
part: {
type: "file",
mime: item.mimeType ?? "text/plain",
mime: "application/x-directory",
filename: item.name,
url: item.uri,
url: pathToFileURL(item.path).href,
source: {
type: "resource",
clientName: item.client,
uri: item.uri,
type: "file",
path: item.name,
text: {
start: 0,
end: 0,
@@ -402,7 +401,7 @@ export function createPromptState(input: PromptInput): PromptState {
},
{ initialValue: [] as Auto[] },
)
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
const hasSkillsCommand = createMemo(() =>
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
@@ -462,7 +461,7 @@ export function createPromptState(input: PromptInput): PromptState {
return [
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
...files(),
...fuzzysort.go(next, resources(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
...fuzzysort.go(next, references(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
]
}
@@ -53,6 +53,9 @@ export function RunFooterSubagentBody(props: {
diffStyle?: RunDiffStyle
onCycle: (dir: -1 | 1) => void
onClose: () => void
// Formatted interrupt shortcut from the registered keymap binding; the
// command itself is dispatched through the keymap in footer.view.
interrupt?: () => string | undefined
}) {
const theme = createMemo(() => props.theme())
const footer = createMemo(() => theme().footer)
@@ -89,6 +92,11 @@ export function RunFooterSubagentBody(props: {
))
let scroll: ScrollBoxRenderable | undefined
const interruptHint = createMemo(() => {
if (tab()?.status !== "running") return undefined
return props.interrupt?.()
})
useKeyboard((event) => {
if (!props.active()) {
return
@@ -139,6 +147,13 @@ export function RunFooterSubagentBody(props: {
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
</Show>
</text>
<Show when={interruptHint()}>
{(hint) => (
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
{hint()} interrupt
</text>
)}
</Show>
<Show when={props.total() > 1 && props.index() > 0}>
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
{props.index()} of {props.total()}
+11 -9
View File
@@ -55,7 +55,7 @@ import type {
RunInput,
RunPrompt,
RunProvider,
RunResource,
RunReference,
RunTuiConfig,
StreamCommit,
} from "./types"
@@ -71,7 +71,7 @@ type RunFooterOptions = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
references: RunReference[]
commands?: RunCommand[]
wrote?: boolean
sessionID: () => string | undefined
@@ -97,6 +97,7 @@ type RunFooterOptions = {
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onExit?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
treeSitterClient?: TreeSitterClient
}
@@ -180,8 +181,8 @@ export class RunFooter implements FooterApi {
private rows = TEXTAREA_MIN_ROWS
private agents: Accessor<RunAgent[]>
private setAgents: Setter<RunAgent[]>
private resources: Accessor<RunResource[]>
private setResources: Setter<RunResource[]>
private references: Accessor<RunReference[]>
private setReferences: Setter<RunReference[]>
private commands: Accessor<RunCommand[] | undefined>
private setCommands: Setter<RunCommand[] | undefined>
private providers: Accessor<RunProvider[] | undefined>
@@ -255,9 +256,9 @@ export class RunFooter implements FooterApi {
const [agents, setAgents] = createSignal(options.agents)
this.agents = agents
this.setAgents = setAgents
const [resources, setResources] = createSignal(options.resources)
this.resources = resources
this.setResources = setResources
const [references, setReferences] = createSignal(options.references)
this.references = references
this.setReferences = setReferences
const [commands, setCommands] = createSignal<RunCommand[] | undefined>(options.commands)
this.commands = commands
this.setCommands = setCommands
@@ -311,7 +312,7 @@ export class RunFooter implements FooterApi {
queuedPrompts: footer.queuedPrompts,
findFiles: options.findFiles,
agents: footer.agents,
resources: footer.resources,
references: footer.references,
commands: footer.commands,
providers: footer.providers,
currentModel: footer.currentModel,
@@ -341,6 +342,7 @@ export class RunFooter implements FooterApi {
onLayout: footer.syncLayout,
onStatus: footer.setStatus,
onSubagentSelect: options.onSubagentSelect,
onSubagentInterrupt: options.onSubagentInterrupt,
onQueuedRemove: footer.handleQueuedRemove,
})
},
@@ -411,7 +413,7 @@ export class RunFooter implements FooterApi {
}
this.setAgents(next.agents)
this.setResources(next.resources)
this.setReferences(next.references)
if (next.commands !== undefined) {
this.setCommands(next.commands)
}
@@ -50,7 +50,7 @@ import type {
RunInput,
RunPrompt,
RunProvider,
RunResource,
RunReference,
RunTuiConfig,
} from "./types"
import type { RunTheme } from "./theme"
@@ -74,7 +74,7 @@ type RunFooterViewProps = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: () => RunAgent[]
resources: () => RunResource[]
references: () => RunReference[]
commands: () => RunCommand[] | undefined
providers: () => RunProvider[] | undefined
currentModel: () => RunInput["model"]
@@ -108,6 +108,7 @@ type RunFooterViewProps = {
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
onStatus: (text: string) => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
onQueuedRemove: (messageID: string) => Promise<boolean>
}
@@ -213,6 +214,15 @@ export function RunFooterView(props: RunFooterViewProps) {
props.tuiConfig,
) ?? "",
)
const subagentInterruptShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
.get("subagent.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const interrupt = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
@@ -358,7 +368,7 @@ export function RunFooterView(props: RunFooterViewProps) {
directory: props.directory,
findFiles: props.findFiles,
agents: props.agents,
resources: props.resources,
references: props.references,
commands: props.commands,
tuiConfig: props.tuiConfig,
state: props.state,
@@ -520,7 +530,7 @@ export function RunFooterView(props: RunFooterViewProps) {
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(),
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
priority: 1,
commands: [
{
@@ -561,6 +571,32 @@ export function RunFooterView(props: RunFooterViewProps) {
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled:
active().type === "prompt" &&
route().type === "subagent" &&
selectedTab()?.status === "running" &&
!!props.onSubagentInterrupt,
priority: 1,
commands: [
{
name: "subagent.interrupt",
title: "Interrupt subagent",
category: "Session",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
return
}
props.onSubagentInterrupt?.(current.sessionID)
},
},
],
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
}))
createEffect(() => {
const current = route()
if (current.type !== "subagent") {
@@ -935,6 +971,7 @@ export function RunFooterView(props: RunFooterViewProps) {
diffStyle={props.diffStyle}
onCycle={cycleTab}
onClose={closeTab}
interrupt={() => subagentInterruptShortcut() || undefined}
/>
</box>
</Show>
@@ -0,0 +1,459 @@
import type {
OpencodeClient,
ReasoningPart,
StepFinishPart,
StepStartPart,
TextPart,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { EOL } from "node:os"
import { MessageID } from "@/session/schema"
import { UI } from "../../ui"
type Model = {
providerID: string
modelID: string
}
type File = {
url: string
filename: string
mime: string
}
type Input = {
client: OpencodeClient
sessionID: string
message: string
files: File[]
agent?: string
model?: Model
variant?: string
thinking: boolean
format: "default" | "json"
dangerouslySkipPermissions: boolean
renderTool: (part: ToolPart) => Promise<void>
renderToolError: (part: ToolPart) => Promise<void>
}
type StartedPart = {
id: string
timestamp: number
}
type ToolState = StartedPart & {
assistantMessageID: string
tool: string
input: Record<string, unknown>
raw?: string
provider?: unknown
}
export async function runNonInteractivePrompt(input: Input) {
const controller = new AbortController()
const events = await input.client.v2.event.subscribe({
signal: controller.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator<V2Event>
const connected = await stream.next()
if (connected.done) throw new Error("Event stream disconnected before prompt admission")
const messageID = MessageID.ascending()
const starts = new Map<string, StartedPart>()
const tools = new Map<string, ToolState>()
let submitted = false
let promoted = false
let emittedError = false
let questionRejected = false
let permissionRejected = false
let interrupted = false
let admission: AbortController | undefined
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
if (input.format !== "json") return false
process.stdout.write(JSON.stringify({ type, timestamp, sessionID: input.sessionID, ...data }) + EOL)
return true
}
const writeText = (part: TextPart, timestamp: number) => {
if (emit("text", timestamp, { part })) return
const text = part.text.trim()
if (!text) return
if (!process.stdout.isTTY) {
process.stdout.write(text + EOL)
return
}
UI.empty()
UI.println(text)
UI.empty()
}
const replyPermission = async (request: { id: string; action: string; resources: string[] }) => {
if (!input.dangerouslySkipPermissions) {
permissionRejected = true
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
)
}
await input.client.v2.session.permission
.reply({
sessionID: input.sessionID,
requestID: request.id,
reply: input.dangerouslySkipPermissions ? "once" : "reject",
})
.catch(() => {})
if (!input.dangerouslySkipPermissions) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
}
const rejectQuestion = async (request: { id: string }) => {
questionRejected = true
await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
}
const consume = async () => {
while (!controller.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("Event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await replyPermission(event.data)
continue
}
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await rejectQuestion(event.data)
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now()
if (event.type === "session.next.prompted") {
if (event.data.messageID === messageID) {
promoted = true
continue
}
if (promoted && event.data.delivery === "queue") return
}
if (
event.type === "session.next.execution.settled" &&
event.data.outcome === "interrupted" &&
(interrupted || permissionRejected || questionRejected)
) {
return
}
if (!promoted) continue
if (event.type === "session.next.step.started") {
const part: StepStartPart = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "step-start",
snapshot: event.data.snapshot,
}
if (!emit("step_start", time, { part }) && input.format !== "json") {
UI.empty()
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
UI.empty()
}
continue
}
if (event.type === "session.next.text.started") {
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "session.next.text.ended") {
const started = starts.get(event.data.textID)
const part: TextPart = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "text",
text: event.data.text,
time: { start: started?.timestamp ?? time, end: time },
}
writeText(part, time)
continue
}
if (event.type === "session.next.reasoning.started") {
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "session.next.reasoning.ended" && input.thinking) {
const started = starts.get(event.data.reasoningID)
const part: ReasoningPart = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "reasoning",
text: event.data.text,
metadata: event.data.providerMetadata,
time: { start: started?.timestamp ?? time, end: time },
}
if (emit("reasoning", time, { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) {
process.stdout.write(line + EOL)
continue
}
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
continue
}
if (event.type === "session.next.tool.input.started") {
tools.set(event.data.callID, {
id: partID(event.id),
timestamp: time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.name,
input: {},
})
continue
}
if (event.type === "session.next.tool.input.ended") {
const current = tools.get(event.data.callID)
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.next.tool.called") {
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.tool,
input: event.data.input,
raw: current?.raw,
provider: event.data.provider,
})
continue
}
if (event.type === "session.next.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
tool: current.tool,
state: {
status: "completed",
input: current.input,
output: event.data.content
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n"),
title: current.tool,
metadata: {
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
providerCall: current.provider,
providerResult: event.data.provider,
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) await input.renderTool(part)
continue
}
if (event.type === "session.next.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message
const part: ToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
tool: current.tool,
state: {
status: "error",
input: current.input,
error,
metadata: {
result: event.data.result,
providerCall: current.provider,
providerResult: event.data.provider,
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) {
await input.renderToolError(part)
UI.error(error)
}
continue
}
if (event.type === "session.next.step.ended") {
const part: StepFinishPart = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "step-finish",
reason: event.data.finish,
snapshot: event.data.snapshot,
cost: event.data.cost,
tokens: event.data.tokens,
}
emit("step_finish", time, { part })
continue
}
if (event.type === "session.next.step.failed") {
if (interrupted || permissionRejected || questionRejected) continue
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "session.next.execution.settled") {
if (event.data.outcome === "failure" && !emittedError && !questionRejected) {
emittedError = true
process.exitCode = 1
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message)
}
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
return
}
}
}
const interrupt = () => {
if (interrupted) process.exit(130)
interrupted = true
process.exitCode = 130
admission?.abort()
void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
process.on("SIGINT", interrupt)
let completed: Promise<void> | undefined
try {
if (input.agent) {
await input.client.v2.session.switchAgent(
{ sessionID: input.sessionID, agent: input.agent },
{ throwOnError: true },
)
}
const selected = input.model
? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant }
: input.variant
? await input.client.v2.session
.get({ sessionID: input.sessionID }, { throwOnError: true })
.then((result) => result.data.data.model)
.then(async (model) => {
if (model) return { ...model, variant: input.variant }
const result = await input.client.v2.model.default(undefined, { throwOnError: true })
const fallback = result.data.data
return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined
})
: undefined
if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected) {
await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true })
}
const prepared = await Promise.all(input.files.map(prepareFile))
if (interrupted) return
submitted = true
completed = consume()
admission = new AbortController()
const response = await input.client.v2.session
.prompt(
{
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
},
delivery: "steer",
},
{ throwOnError: true, signal: admission.signal },
)
.catch(async (error) => {
if (interrupted) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
controller.abort()
await completed?.catch(() => {})
if (interrupted) return undefined
throw error
})
admission = undefined
if (!response) return
if (!response.data.data) throw new Error("Prompt was not admitted")
if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
const [permissions, questions] = await Promise.all([
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
])
await Promise.all([
...(permissions?.data?.data ?? []).map(replyPermission),
...(questions?.data?.data ?? []).map(rejectQuestion),
])
await completed
} finally {
process.off("SIGINT", interrupt)
controller.abort()
await stream.return?.(undefined).catch(() => {})
}
}
function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}`
}
function fallbackTool(event: {
id: string
data: { timestamp: number; assistantMessageID: string; callID: string }
}): ToolState {
return {
id: partID(event.id),
timestamp: toMillis(event.data.timestamp),
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},
}
}
function toMillis(value: unknown) {
if (typeof value === "number") return value
if (typeof value === "string") return new Date(value).getTime()
return Date.now()
}
async function prepareFile(file: File) {
if (file.mime !== "text/plain") {
const uri = file.url.startsWith("data:")
? file.url
: `data:${file.mime};base64,${Buffer.from(await Bun.file(new URL(file.url)).arrayBuffer()).toString("base64")}`
return { attachment: { uri, mime: file.mime, name: file.filename } }
}
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
: await Bun.file(new URL(file.url)).text()
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
}
@@ -150,8 +150,11 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply
}
}
export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
const list = permissionOptions(state.stage)
export function permissionShift(
state: PermissionBodyState,
dir: -1 | 1,
list = permissionOptions(state.stage),
): PermissionBodyState {
if (list.length === 0) {
return state
}
@@ -8,9 +8,12 @@
import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@/effect/run-service"
import { loadRunProviders } from "./catalog.shared"
import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared"
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import { pickVariant } from "./variant.shared"
@@ -95,20 +98,7 @@ const layer = Layer.effect(
directory: string,
model: RunInput["model"],
) {
const connected = yield* Effect.promise(() =>
sdk.config
.providers({ directory })
.then((item) => item.data?.providers)
.catch(() => undefined),
)
const providers = yield* Effect.promise(() =>
connected
? Promise.resolve(connected)
: sdk.provider
.list()
.then((item) => item.data?.all ?? [])
.catch(() => []),
)
const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory))
const limits = Object.fromEntries(
providers.flatMap((provider) =>
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
@@ -143,7 +133,7 @@ const layer = Layer.effect(
sessionID: string,
model: RunInput["model"],
) {
const session = yield* Effect.promise(() => resolveSession(sdk, sessionID).catch(() => undefined))
const session = yield* Effect.promise(() => resolveCurrentSession(sdk, sessionID).catch(() => undefined))
if (!session) {
return emptySessionInfo()
}
@@ -172,7 +162,8 @@ const layer = Layer.effect(
}),
)
const runtime = makeRuntime(Service, layer)
const node = makeGlobalNode({ service: Service, layer, deps: [] })
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
// Fetches available variants and context limits for every provider/model pair.
export async function resolveModelInfo(
@@ -27,7 +27,7 @@ import type {
RunAgent,
RunInput,
RunPrompt,
RunResource,
RunReference,
RunTuiConfig,
} from "./types"
import { formatModelLabel } from "./variant.shared"
@@ -55,7 +55,7 @@ export type LifecycleInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
references: RunReference[]
sessionID: string
sessionTitle?: string
getSessionID?: () => string | undefined
@@ -75,6 +75,7 @@ export type LifecycleInput = {
onInterrupt?: () => void
onBackground?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
}
export type Lifecycle = {
@@ -233,7 +234,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
directory: input.directory,
findFiles: input.findFiles,
agents: input.agents,
resources: input.resources,
references: input.references,
sessionID: input.getSessionID ?? (() => input.sessionID),
...labels,
model: input.model,
@@ -276,6 +277,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
}
},
onSubagentSelect: input.onSubagentSelect,
onSubagentInterrupt: input.onSubagentInterrupt,
})
const sigint = () => {
@@ -1,7 +1,7 @@
import fs from "fs"
import * as tty from "node:tty"
export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
type InteractiveStdin = {
stdin: NodeJS.ReadStream

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