Compare commits

..

57 Commits

Author SHA1 Message Date
Aiden Cline e06a099a88 fix(opencode): let NaN/Infinity flow in Rune, normalize to null at the boundary
Rune previously threw the instant any non-finite number materialized, which
killed the program before the model's own guard could run — so idiomatic code
like parseInt(x) || 0, Number(x) then Number.isNaN(x), averages, and counters
crashed mid-expression. Real JS (and a real-engine sandbox) let these values
flow and rely on JSON serialization to turn them into null at the edge.

- copyIn no longer rejects non-finite numbers, so NaN/Infinity exist as ordinary
  in-sandbox intermediates and defensive guards can run.
- copyOut normalizes non-finite numbers to null as a value leaves the sandbox;
  final return and tool-call arguments both funnel through it, so one check pins
  both boundaries (matching JSON.stringify, which produces null anyway).
- NaN/Infinity are now bindable identifiers (e.g. reduce(max, -Infinity)).

Extends rune-parity.test.ts (guards run, non-finite -> null out incl. nested, a
direct copyOut unit test) and updates rune.md.
2026-07-01 12:26:55 -05:00
Aiden Cline cc437b9e9a fix(opencode): make Rune tolerate idiomatic defensive JavaScript
Bring the Rune interpreter closer to JS semantics so ordinary defensive code
stops crashing where real JS would quietly yield undefined or succeed:

- Unknown property reads on strings, numbers, and arrays now yield undefined
  instead of throwing (including under optional chaining, which only guards
  null/undefined receivers). MCP results are frequently JSON strings, so
  `result?.field ?? result` reads the field when present and falls back to the
  raw string otherwise. The method allowlist still errors (e.g. arr.splice keeps
  its rewrite hint).
- `typeof undeclaredIdentifier` is now "undefined" rather than a reference
  error, so `typeof x !== "undefined"` feature-detection guards are safe.
- Object spread of null/undefined is a no-op, so `{ ...maybeOpts, override }`
  merges work when the operand is absent.
- Builtin coercions (Boolean/String/Number) are accepted as array callbacks, so
  filter(Boolean) / map(String) / map(Number) work.

Adds rune-parity.test.ts covering each fix plus its guardrails, and documents
the parity behavior in rune.md.
2026-07-01 12:20:54 -05:00
Aiden Cline 064c34b25a feat(opencode): capture console output and surface it to the model
- console.log/warn/error/info/debug are now a real Rune interpreter builtin:
  a seeded global that formats its args (strings verbatim, objects/arrays as
  JSON, space-joined) and appends a line to a per-run LogCollector. It is not a
  tool call (spends no tool-call budget), returns undefined, and any other
  member throws. Formatting is charged to maxOperations and total captured
  output is bounded by maxAuditBytes so a logging loop can't exhaust memory.
- The collector is shared by reference with parallel interpreter forks (like the
  operation budget) and lives in execute()'s outer scope, so logs are surfaced
  on every ExecuteResult path — success, thrown error, and timeout.
- ExecuteResult (and its schema) gain a logs field; code mode appends captured
  logs to the model-facing output as a trailing '[level] message' section on
  both the success and error paths (withLogs). Logs go to the model only.
- Documents console in rune.md; drops the now-satisfied 'not done yet' entry.
2026-07-01 11:36:37 -05:00
Aiden Cline 2d9015c30d fix(tui): simplify execute running state 2026-07-01 11:02:01 -05:00
Aiden Cline c16bba8bc0 feat(opencode): JSDoc tags, Result<T> return hints, and opaque attachments
- describe/preview now surface each tool's return type as Result<T> (alias
  defined once in the prompt); the inline preview shows it too, so the model
  sees a tool's result shape without a describe round-trip. T is the declared
  outputSchema else unknown, with prose telling the model to inspect an unknown
  result before assuming fields.
- renderType pretty mode emits JSDoc tags a TS type can't express: @default,
  @format, @deprecated, @minItems/@maxItems (multi-line descriptions preserved).
- Attachments are now opaque handles: a tool's media becomes
  { type:'file', id, mime, filename?, bytes } with no inline bytes. Real bytes
  stay host-side in a per-execution attachmentTable; the program propagates or
  drops a handle (return it to surface the image to the model+user) but cannot
  read or leak the base64. Documents the divergence from prior art in rune.md.
- Records the throw/catch (not errors-as-values) decision in rune.md.
2026-07-01 10:54:42 -05:00
Aiden Cline 5085a13b0e feat(opencode): render code-mode types as TypeScript, not JSON Schema
tools.$rune.describe now returns { path, description, signature, input,
output? } with input/output as TypeScript instead of raw JSON Schema.
Rewrite renderType into a total, cycle-safe JSON-Schema -> TS renderer:
resolve local $refs (collapsing recursive refs to their name), render
enums/const as literals, anyOf/oneOf and nullable type arrays as unions,
allOf as an intersection (unwrapping the Pydantic allOf:[{$ref}] shape),
tuples, and additionalProperties as an index signature. Pretty mode emits
JSDoc (multi-line preserved) for described fields, with */ neutralized.
Falls back to any/object and never throws; depth-capped.

Docs updated; code-mode/renderType test coverage expanded.
2026-06-30 22:34:23 -05:00
Aiden Cline 05b934616b fix(tui): surface execute child call details 2026-06-30 19:23:49 -05:00
Aiden Cline 2726a74203 fix(tui): normalize execute tool styling 2026-06-30 18:03:23 -05:00
Aiden Cline bc427b11b7 feat: live code-mode execute UI in the TUI
Backend: code-mode execute now streams per-call progress. Metadata.toolCalls
becomes CallEntry[] ({ tool: dotted-path, status: running|completed|error })
and is published via ctx.metadata on every status change — when a child MCP
call starts and when it resolves/fails — so the tool part updates live.

TUI: add an Execute component (dispatched for the "execute" tool). Condensed
view shows a run header (spinner while running, ✓ when done) plus a live `↳`
line per child tool call, colored on failure; clicking toggles a syntax-
highlighted view of the program source. Unlike Task, there is no child session,
so the call list is sourced entirely from the streamed metadata.
2026-06-30 17:13:16 -05:00
Aiden Cline dc75ea0cc0 feat(opencode): state whether the code mode tool list is complete or partial
The preview now reports its own comprehensiveness so the model knows when it
has the whole catalog vs. when it must search:

- Overall: "This is the COMPLETE list ..." when every tool fits the budget,
  else "This is a PARTIAL list — X of Y tools are shown ...".
- Per namespace: a fully-shown server reads `- github (2 tools)`; a truncated
  one is annotated `- alpha (70 tools, 31 shown)` or `- zeta (1 tool, none shown)`.

When complete, the model isn't pushed toward needless $rune.search calls; when
partial, exactly what's missing is unambiguous.
2026-06-30 16:56:37 -05:00
Aiden Cline 55aa8cce44 feat(opencode): inline budgeted call signatures in code mode preview
The preview previously showed name + prose per tool but no parameters, so
the model couldn't call a tool correctly without a $rune.describe round-trip
(or a failed guess — e.g. calling context7 resolve-library-id with only
libraryName, missing the required query). Replace the prose preview with a
compact, directly-callable input signature:

  tools.context7["resolve-library-id"](input: { query: string; libraryName: string })

All namespaces are still always listed with counts; the budget now caps
inlined signatures, and the description states explicitly that any tool not
shown must be found via $rune.search/$rune.describe first. The full typed
signature (with the Promise return) and schemas remain $rune.describe-only.
2026-06-30 15:46:30 -05:00
Aiden Cline 394c37084b docs(opencode): add rune.md (how it works, what's missing) 2026-06-30 15:03:45 -05:00
Aiden Cline 79275e60fb fix(opencode): describe attachments as routable values, not opaque
The previous wording claimed attachment bytes 'aren't available in code',
but a tool result's { result, attachments } envelope is copied straight
into the sandbox (tool-runtime invoke -> checkedCopyIn), so attachment.url
is a real data: URL string the program can read and route — e.g. feed one
tool's media into another tool's input, which is exactly what code mode is
uniquely good at. Reword the description to say so. Still not the emit
pattern: only `result` becomes conversation text; returned attachments
lower to FileParts and nothing else in the sandbox re-enters the chat.

Add an integration test asserting the data URL is readable in-program.
2026-06-30 14:10:06 -05:00
Aiden Cline cbcc67b1e2 refactor(opencode): single source of discovery docs, clearer attachment wording
- Reword the attachment line in the execute description: attachments are
  opaque media handles you forward to the user, not byte-readable in code.
- Strip the hardcoded tools.$rune.search/describe block from the runtime's
  instructions(): discovery is not a runtime feature (the embedder registers
  and documents it), and the baked-in object-arg form contradicted code
  mode's positional API. Removes the only prompt discrepancy.
- Make the runtime's unknown-tool suggestion generic instead of naming $rune.

Discovery is now documented in exactly one place (code-mode describe()).
2026-06-30 13:59:33 -05:00
Aiden Cline a3bfba809f feat(opencode): unify code mode discovery under tools.$rune
Remove Rune's vestigial built-in $rune.search/$rune.describe (which only
saw effect-Schema Definitions, never our dynamic MCP tools) and the
reserved-namespace guard. Move our ranked search and typed describe under
tools.$rune.* — the runtime's own namespace, separate from MCP server
namespaces and collision-proof since $ never appears in a sanitized
server name. One discovery implementation, no dead code.
2026-06-30 11:11:55 -05:00
Aiden Cline ba12049ca5 feat(opencode): tokenized, ranked tool search in code mode
Replace the contiguous-substring search with tokenized, field-weighted
scoring adapted from the deferred-tool-search bridge (#34368): exact tool
name > path > description > indexed parameter text, summed across terms
and ranked by relevance. Index each tool's parameter names/descriptions
so tools are findable by their inputs. Keep the namespace filter and
{ items, total } shape. Add unit + end-to-end search tests.
2026-06-30 10:08:02 -05:00
Aiden Cline acc1742859 test(opencode): end-to-end code mode test over a real MCP server
Stand up an in-memory MCP server with text, structured (outputSchema),
image, and failing tools, wire it through convertTool + define, and
exercise search/describe, the result+attachments envelope, structured
composition, image forwarding/suppression, parallel calls, error
propagation, and per-call permission gating.
2026-06-30 09:55:25 -05:00
Aiden Cline 1448f248fd feat(opencode): budgeted tool preview in code mode description
Always list every MCP namespace, then inline a preview of individual
tools (path + brief) until a character budget is hit; remaining
namespaces show counts only. Front-loads a useful slice of the catalog
to cut discovery round-trips without dumping the full tool list.
2026-06-30 09:49:52 -05:00
Aiden Cline 71ffc73272 feat(opencode): run code mode on the vendored rune interpreter
Replace the in-process AsyncFunction engine with Rune.execute. MCP tools
are exposed as a host-tool tree (tools.<server>.<tool>) plus top-level
search/describe; each call is permission-gated and coerced to the
{ result, attachments? } envelope. Raise data limits for base64 media.

This adds real sandboxing: host globals are isolated and runaway loops
terminate via the operation limit instead of hanging the event loop.
2026-06-30 09:36:59 -05:00
Aiden Cline 14527d2047 feat(opencode): code mode result+attachments envelope and typed describe
Expose mcp.defs() so code mode can read MCP outputSchema. Tool calls and
the final return now use one { result, attachments? } envelope; media
blocks become FilePart attachments. describe renders typed signatures
with the structured return type when an outputSchema is present.
2026-06-30 09:27:39 -05:00
Aiden Cline 3a6621c5fc chore(opencode): vendor rune interpreter for code mode
Copy the Rune TS AST interpreter into the session package as the
foundation for code-mode execution. Add acorn as a dependency and
promote typescript to a runtime dependency (both required by Rune's
parser/transpile path). Fix effect beta.83 API drift (Schema.Defect
is now a function). Interpreter logic is unchanged; nothing imports
it yet.
2026-06-30 00:05:58 -05:00
Aiden Cline caa5f28cc9 refactor(opencode): simplify code mode namespace listing
Drop the MCP-instructions blending from the execute tool description; just
list namespace names and tool counts on the tool definition. The full server
instructions already live in the system prompt's <mcp_instructions>, and
per-tool detail is fetched on demand via tools.search/tools.describe.
2026-06-29 18:46:28 -05:00
Aiden Cline cad83ab53e feat(opencode): progressive tool discovery for code mode
Shrink the execute tool description to namespaces only (server name, tool
count, and a brief note reused from the server's MCP instructions) instead of
inlining every tool signature, so the prompt stays small for large catalogs.

Add in-sandbox discovery: `tools.search(query, { namespace?, limit? })` returns
ranked tool paths + descriptions, and `tools.describe(path)` returns the full
signature and input schema on demand (with tool_not_found suggestions). The
proxy is now recursive so both `tools.<server>.<tool>(args)` and the dotted
`tools[path](args)` returned by search resolve to the catalog key.
2026-06-29 18:39:55 -05:00
Aiden Cline b0aa6bfb61 feat(opencode): namespace code mode tools by MCP server
Expose connected MCP tools to code mode as per-server namespaces
(`tools.<server>.<tool>(args)`) and generate the execute tool description
from the catalog: one namespace block per server, each tool rendered with a
TypeScript-style input signature derived from its JSON schema plus its
description, and the calling convention stated up front.

The server/tool split is cosmetic; child-call routing re-joins the segments
into the existing flat catalog key, so it stays exact regardless of
underscores in server or tool names.
2026-06-29 18:33:08 -05:00
Aiden Cline 49f20b6a30 feat(opencode): add experimental code mode execute tool
Add an experimental, off-by-default `execute` tool that runs LLM-authored
JavaScript with a `tools.<name>(args)` proxy over connected MCP tools. When
OPENCODE_EXPERIMENTAL_CODE_MODE is enabled and MCP tools are present, the
session exposes the single code-mode tool instead of registering each MCP
tool directly; child calls route through the native permission path.

Code mode is defined via the standard Tool.define/Tool.init machinery so it
inherits arg decoding and output truncation from the shared wrapper. Tool
results are reduced to structured content or text, and the program's return
value is coerced to text without failing on shape.

Note: execution currently uses an in-process AsyncFunction with no isolation
or timeout; sandboxing is tracked as follow-up work.
2026-06-29 18:18:25 -05:00
opencode-agent[bot] 78235385dd chore: generate 2026-06-29 20:53:48 +00:00
Aiden Cline fd213e6df6 fix(mcp): prefer content over structured output (#34505) 2026-06-29 15:51:52 -05:00
James Long 3726052307 refactor(opencode): use layer nodes in server tests (#34503) 2026-06-29 16:50:59 -04:00
opencode-agent[bot] 7d33a6f7c9 chore: generate 2026-06-29 20:36:56 +00:00
James Long 7a035d7fc0 refactor(opencode): bind instance bootstrap node (#34502) 2026-06-29 16:35:10 -04:00
opencode-agent[bot] 9151af7045 chore: generate 2026-06-29 20:24:42 +00:00
James Long 15bcbb1d7a refactor(opencode): migrate session tests to layer nodes (#34494) 2026-06-29 16:22:50 -04:00
opencode-agent[bot] 4d3294727c chore: generate 2026-06-29 20:07:10 +00:00
James Long aae0e89519 refactor(opencode): use layer nodes in plugin tests (#34495) 2026-06-29 16:04:58 -04:00
opencode-agent[bot] 93a7b4ab76 chore: generate 2026-06-29 19:58:09 +00:00
James Long 0ebe74b625 refactor(opencode): migrate llm tests to layer nodes (#34479) 2026-06-29 15:55:58 -04:00
James Long b9dae8593c refactor(opencode): migrate compaction and workspace tests to layer nodes (#34478) 2026-06-29 15:42:52 -04:00
opencode-agent[bot] d8f9388610 chore: generate 2026-06-29 18:52:55 +00:00
James Long be14739cce refactor(core): convert config tests to nodes (#34474) 2026-06-29 14:51:13 -04:00
James Long 762588c251 refactor(core): convert prompt tests to nodes (#34470) 2026-06-29 14:25:33 -04:00
opencode-agent[bot] d6e54e9042 chore: generate 2026-06-29 17:58:20 +00:00
James Long d4fd528152 refactor(core): convert more opencode tests to nodes (#34464) 2026-06-29 13:56:28 -04:00
opencode-agent[bot] de185559dd chore: generate 2026-06-29 16:58:51 +00:00
James Long bc5ce5eab1 refactor(core): convert opencode tests to nodes (#34453) 2026-06-29 12:56:44 -04:00
opencode-agent[bot] b10d617c80 chore: generate 2026-06-29 16:17:54 +00:00
Shoubhit Dash 18466b8020 feat(llm): add tool schema projections (#34454) 2026-06-29 21:45:42 +05:30
opencode-agent[bot] 71ec022b47 chore: generate 2026-06-29 15:50:24 +00:00
Shoubhit Dash f7eeb08942 fix(llm): narrow raw overlays (#34448) 2026-06-29 21:18:06 +05:30
opencode-agent[bot] 9205dfe724 chore: generate 2026-06-29 15:37:20 +00:00
James Long a3776429aa refactor(core): finish test layer node conversion (#34385) 2026-06-29 11:35:17 -04:00
opencode-agent[bot] c0e43c0c65 chore: generate 2026-06-29 13:57:06 +00:00
Shoubhit Dash 08c5a2a5e8 feat(llm): enforce request precedence (#34440) 2026-06-29 19:24:37 +05:30
opencode-agent[bot] 7077c70d60 chore: generate 2026-06-29 13:41:59 +00:00
Shoubhit Dash 1fd8bf526d feat(llm): add model defaults and compatibility data (#34436) 2026-06-29 19:10:00 +05:30
opencode-agent[bot] 6d9539f469 fix: exempt org issues from compliance close (#34431) 2026-06-29 12:59:30 +00:00
Shoubhit Dash e5101d9651 test(llm): lock event reducer laws (#34423) 2026-06-29 17:22:46 +05:30
Shoubhit Dash b0151e1d02 test(llm): verify generate reducer law (#34418) 2026-06-29 17:01:45 +05:30
214 changed files with 8076 additions and 1648 deletions
+14
View File
@@ -34,11 +34,25 @@ jobs:
const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000;
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
if (orgMemberAssociations.has(item.author_association)) {
core.info(`Skipping ${kind} #${item.number}; author association is ${item.author_association}`);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
continue;
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
+7 -1
View File
@@ -38,6 +38,7 @@ jobs:
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
Issue number: ${{ github.event.issue.number }}
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
@@ -49,6 +50,8 @@ jobs:
Check whether the issue follows our contributing guidelines and issue templates.
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content
@@ -83,7 +86,7 @@ jobs:
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
If the issue is NOT compliant, start the comment with:
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
<!-- issue-compliance -->
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
@@ -148,9 +151,12 @@ jobs:
}
run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
Re-check whether the issue now follows our contributing guidelines and issue templates.
This project has three issue templates that every issue MUST use one of:
+21 -2
View File
@@ -603,6 +603,7 @@
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"acorn": "8.15.0",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
@@ -636,6 +637,7 @@
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"typescript": "catalog:",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.2",
"vscode-jsonrpc": "8.2.1",
@@ -664,7 +666,6 @@
"@typescript/native-preview": "catalog:",
"drizzle-orm": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
},
@@ -3009,7 +3010,7 @@
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -5691,6 +5692,8 @@
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
"@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -5901,6 +5904,8 @@
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
@@ -6147,6 +6152,8 @@
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
"astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
@@ -6237,6 +6244,8 @@
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -6301,6 +6310,8 @@
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
"micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
@@ -6425,6 +6436,8 @@
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
@@ -6443,6 +6456,8 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
@@ -6457,6 +6472,8 @@
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
@@ -6875,6 +6892,8 @@
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
@@ -1,10 +1,11 @@
import { Match, Show, Switch, createEffect, createMemo } from "solid-js"
import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
@@ -12,6 +13,7 @@ import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -32,6 +34,7 @@ function openSessionContext(args: {
export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const file = useFile()
const layout = useLayout()
const language = useLanguage()
const sdk = useSDK()
@@ -40,6 +43,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -57,27 +65,15 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return usd().format(info()?.cost ?? 0)
})
let openedPanelWithContext = false
createEffect(() => {
if (!view().reviewPanel.opened()) openedPanelWithContext = false
})
const openContext = () => {
if (!params.id) return
const sessionView = view()
if (sessionView.reviewPanel.opened() && tabs().active() === "context") {
const hasOtherTabs = tabs().all().some((tab) => tab !== "context" && tab !== "review")
if (tabState.activeTab() === "context") {
tabs().close("context")
if (openedPanelWithContext && !hasOtherTabs) sessionView.reviewPanel.close()
openedPanelWithContext = false
return
}
openedPanelWithContext = !sessionView.reviewPanel.opened()
openSessionContext({
view: sessionView,
view: view(),
layout,
tabs: tabs(),
})
@@ -85,20 +81,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const circle = () => (
<div class="flex items-center justify-center">
<ProgressCircle
size={16}
strokeWidth={2}
percentage={context()?.usage ?? 0}
style={
variant() === "indicator"
? {
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
}
: undefined
}
/>
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
</div>
)
const circleV2 = () => (
@@ -136,10 +119,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return (
<Show when={params.id}>
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={buttonAppearance() === "v2"}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={buttonAppearance() === "v2"}>
<IconButtonV2
type="button"
variant="ghost-muted"
@@ -148,10 +131,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
onClick={openContext}
aria-label={language.t("context.usage.view")}
/>
</Tooltip>
</Match>
<Match when={true}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
</Match>
<Match when={true}>
<Button
type="button"
variant="ghost"
@@ -161,9 +142,9 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
>
{circle()}
</Button>
</Tooltip>
</Match>
</Switch>
</Match>
</Switch>
</Tooltip>
</Show>
)
}
@@ -1,6 +1,7 @@
export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event"
import { Git } from "../git"
import { Location } from "../location"
@@ -146,3 +147,9 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
})
+44 -1
View File
@@ -272,7 +272,50 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
}
function replacementMapFrom(replacements?: Replacements) {
return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
return (
replacements?.reduce((map, [source, replacement]) => {
const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map)
const current = new Map([[source.name, normalized]])
for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current))
map.set(source.name, normalized)
return map
}, new Map<string, AnyNode>()) ?? new Map<string, AnyNode>()
)
}
function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap<string, AnyNode>) {
if (replacements.size === 0) return root
const cache = new Map<AnyNode, AnyNode>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const recur = (node: AnyNode, isRoot = false): AnyNode => {
const target = isRoot ? node : (replacements.get(node.name) ?? node)
const cached = cache.get(target)
if (cached !== undefined || cache.has(target)) return cached!
if (visiting.has(target)) {
const start = stack.indexOf(target)
throw new Error(
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
)
}
visiting.add(target)
stack.push(target)
try {
const dependencies = target.dependencies.map((dependency) => recur(dependency))
const result = dependencies.every((dependency, index) => dependency === target.dependencies[index])
? target
: { ...target, dependencies }
cache.set(target, result)
return result
} finally {
stack.pop()
visiting.delete(target)
}
}
return recur(root, true)
}
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
+3
View File
@@ -1,6 +1,7 @@
export * as Observability from "./observability"
import { NodeFileSystem } from "@effect/platform-node"
import { LayerNode } from "./effect/layer-node"
import { Effect, Layer, Logger, References } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpSerialization } from "effect/unstable/observability"
@@ -19,3 +20,5 @@ export const layer = Layer.unwrap(
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
}),
)
export const node = LayerNode.make({ name: "observability", layer, deps: [] })
+8
View File
@@ -4,11 +4,13 @@ import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -194,6 +196,12 @@ export const layer = Layer.effectDiscard(
}),
)
export const node = makeLocationNode({
name: "tool/apply-patch",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({
+8
View File
@@ -5,11 +5,13 @@ import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config"
import { makeLocationNode } from "../effect/app-node"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { AppProcess } from "../process"
import { PermissionV2 } from "../permission"
import { PositiveInt } from "../schema"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -193,3 +195,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/bash",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, AppProcess.node, Config.node, PermissionV2.node],
})
+13 -30
View File
@@ -15,20 +15,6 @@ import { TodoWriteTool } from "./todowrite"
import { WebFetchTool } from "./webfetch"
import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write"
import { FSUtil } from "../fs-util"
import { AppProcess } from "../process"
import { Config } from "../config"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { FileMutation } from "../file-mutation"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { Image } from "../image"
import { QuestionV2 } from "../question"
import { SkillV2 } from "../skill"
import { SessionTodo } from "../session/todo"
import { ToolRegistry } from "./registry"
import { httpClient } from "../effect/app-node-platform"
/**
* Composes only the shipped Location-scoped built-in tool transforms.
@@ -60,22 +46,19 @@ export const locationLayer = Layer.mergeAll(
export const node = makeLocationNode({
name: "built-in-tools",
layer: locationLayer,
layer: Layer.empty,
deps: [
ToolRegistry.toolsNode,
FSUtil.node,
AppProcess.node,
Config.node,
Location.node,
LocationMutation.node,
FileMutation.node,
PermissionV2.node,
Ripgrep.node,
Image.node,
QuestionV2.node,
SkillV2.node,
SessionTodo.node,
ReadToolFileSystem.node,
httpClient,
ApplyPatchTool.node,
BashTool.node,
EditTool.node,
GlobTool.node,
GrepTool.node,
QuestionTool.node,
ReadTool.node,
SkillTool.node,
TodoWriteTool.node,
WebFetchTool.node,
WebSearchTool.node,
WriteTool.node,
],
})
+8
View File
@@ -10,10 +10,12 @@ import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -213,3 +215,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/edit",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
+8
View File
@@ -3,11 +3,13 @@ export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import path from "path"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -95,3 +97,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/glob",
layer,
deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node],
})
+8
View File
@@ -3,12 +3,14 @@ export * as GrepTool from "./grep"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import path from "path"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -127,3 +129,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/grep",
layer,
deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
+8
View File
@@ -2,8 +2,10 @@ export * as QuestionTool from "./question"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -84,3 +86,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/question",
layer,
deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
})
+8
View File
@@ -2,12 +2,14 @@ export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem"
import { Image } from "../image"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -107,3 +109,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/read",
layer,
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
})
+8
View File
@@ -3,9 +3,11 @@ export * as SkillTool from "./skill"
import path from "path"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FSUtil } from "../fs-util"
import { SkillV2 } from "../skill"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -99,3 +101,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/skill",
layer,
deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node],
})
+8
View File
@@ -2,8 +2,10 @@ export * as TodoWriteTool from "./todowrite"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { PermissionV2 } from "../permission"
import { SessionTodo } from "../session/todo"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -52,3 +54,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/todowrite",
layer,
deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node],
})
+9
View File
@@ -5,8 +5,11 @@ import { Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform"
import { PermissionV2 } from "../permission"
import { collectBoundedResponseBody } from "./http-body"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -177,6 +180,12 @@ export const layer = Layer.effectDiscard(
}),
)
export const node = makeLocationNode({
name: "tool/webfetch",
layer,
deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient],
})
export function extractTextFromHTML(html: string) {
let text = ""
let skipDepth = 0
+11
View File
@@ -3,6 +3,8 @@ export * as WebSearchTool from "./websearch"
import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform"
import { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema"
@@ -11,6 +13,7 @@ import { Tool } from "./tool"
import { Tools } from "./tools"
import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode"
import { ToolRegistry } from "./registry"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
@@ -80,6 +83,8 @@ export const defaultConfigLayer = Layer.sync(ConfigService, () =>
}),
)
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
export function selectProvider(
sessionID: string,
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
@@ -247,3 +252,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/websearch",
layer,
deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode],
})
+8
View File
@@ -8,9 +8,11 @@ export * as WriteTool from "./write"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -91,3 +93,9 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
export const node = makeLocationNode({
name: "tool/write",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node],
})
+2 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -8,7 +9,7 @@ import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host"
const it = testEffect(AgentV2.locationLayer)
const it = testEffect(AppNodeBuilder.build(AgentV2.node))
describe("AgentV2", () => {
it.effect("starts without agents", () =>
+6 -3
View File
@@ -1,8 +1,11 @@
import { describe, expect } from "bun:test"
import { BackgroundJob } from "@opencode-ai/core/background-job"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Deferred, Effect, Exit, Scope } from "effect"
import { it } from "./lib/effect"
const jobsLayer = LayerNode.compile(BackgroundJob.node)
describe("BackgroundJob", () => {
it.live("tracks process-local work through explicit observation", () =>
Effect.gen(function* () {
@@ -25,7 +28,7 @@ describe("BackgroundJob", () => {
timedOut: false,
info: { status: "completed", output: "done" },
})
}).pipe(Effect.provide(BackgroundJob.layer)),
}).pipe(Effect.provide(jobsLayer)),
)
it.live("publishes jobs before starting immediately settling work", () =>
@@ -55,7 +58,7 @@ describe("BackgroundJob", () => {
})
})
})
}).pipe(Effect.provide(BackgroundJob.layer)),
}).pipe(Effect.provide(jobsLayer)),
)
it.live("increments pending work before starting immediately settling extensions", () =>
@@ -80,7 +83,7 @@ describe("BackgroundJob", () => {
})
}),
)
}).pipe(Effect.provide(BackgroundJob.layer)),
}).pipe(Effect.provide(jobsLayer)),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
+2 -1
View File
@@ -1,11 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "./lib/effect"
const it = testEffect(CommandV2.locationLayer)
const it = testEffect(AppNodeBuilder.build(CommandV2.node))
describe("CommandV2", () => {
it.effect("applies command transforms and preserves later overrides", () =>
+4 -7
View File
@@ -5,7 +5,6 @@ import { Effect, Layer, Schema } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
@@ -28,11 +27,6 @@ function testLayer(
projectDirectory = directory,
vcs?: Project.Vcs,
) {
const configNode = makeLocationNode({
service: Config.Service,
layer: Layer.fresh(Config.layer.pipe(Layer.provide(Global.layerWith({ config: globalDirectory })))),
deps: [FSUtil.node, Location.node, Policy.node],
})
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
@@ -42,7 +36,10 @@ function testLayer(
),
),
)
return AppNodeBuilder.build(LayerNode.group([configNode, Policy.node]), [[Location.node, locationLayer]])
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory })],
])
}
const provider = {
@@ -15,6 +15,8 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -268,7 +270,6 @@ describe("DatabaseMigration", () => {
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
const database = Layer.succeed(Database.Service, { db })
const events = EventV2.layer.pipe(Layer.provide(database))
yield* EventV2.Service.use((service) =>
service.publish(SessionV1.Event.Updated, {
sessionID: SessionSchema.ID.make("session"),
@@ -284,7 +285,7 @@ describe("DatabaseMigration", () => {
}),
).pipe(
Effect.provide(
Layer.merge(events, SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))),
AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]),
),
)
@@ -69,10 +69,10 @@ void invalidNodeReplacement
// @ts-expect-error Replacement cannot introduce a new error
LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
const nodeReplacementWithError = make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })
// @ts-expect-error Node replacement cannot introduce a new error
LayerNode.compile(a, [[a, nodeReplacementWithError]])
const invalidNodeErrorReplacement = () =>
// @ts-expect-error Node replacement cannot introduce a new error
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
void invalidNodeErrorReplacement
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
@@ -143,6 +143,21 @@ describe("layer node", () => {
expect(acquisitions).toBe(0)
})
test("applies later replacements inside earlier replacement nodes", async () => {
const original = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(
build(LayerNode.group([original]), [
[original, replacement],
[value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))],
]),
),
)
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
})
test("hoists and compiles tagged graphs", async () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
+8 -5
View File
@@ -6,6 +6,8 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -76,9 +78,10 @@ const durableData = (sessionID: Session.ID, text: string) => ({
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
})
const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer)
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(eventLayer)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]),
)
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
describe("EventV2", () => {
it.effect("publishes events with the current location", () =>
@@ -462,7 +465,7 @@ describe("EventV2", () => {
pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}).pipe(Layer.provide(Database.defaultLayer))
}).pipe(Layer.provide(LayerNode.compile(Database.node)))
yield* Effect.gen(function* () {
const events = yield* EventV2.Service
@@ -477,7 +480,7 @@ describe("EventV2", () => {
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "during handoff")],
])
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
}),
)
+10 -5
View File
@@ -2,6 +2,8 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@@ -11,14 +13,17 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
function provide(directory: string, filesystem = FSUtil.defaultLayer) {
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
return Effect.provide(Layer.mergeAll(resolution, mutation))
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
[Location.node, activeLocation],
[FSUtil.node, filesystemLayer],
]),
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
@@ -359,5 +364,5 @@ function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string
run(filesystem.writeFileString(target, content, options), target),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
}
+9 -13
View File
@@ -4,10 +4,11 @@ import fs from "fs/promises"
import path from "path"
import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Git } from "@opencode-ai/core/git"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@@ -18,7 +19,7 @@ const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))
const configLayer = Layer.succeed(
Config.Service,
@@ -40,12 +41,10 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
return Effect.provide(
Watcher.layer.pipe(
Layer.provide(configLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(locationLayer),
Layer.provide(flagsLayer),
),
AppNodeBuilder.build(Watcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
]).pipe(Layer.provide(flagsLayer)),
)
}
@@ -196,7 +195,7 @@ describeWatcher("Watcher", () => {
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
Effect.provideService(EventV2.Service, events),
)
}).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))),
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))),
)
it.live("ignores .git/index changes", () =>
@@ -228,10 +227,7 @@ describeWatcher("Watcher", () => {
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toEqual({
file: head,
event: "change",
})
).toMatchObject({ file: head })
}),
{ git: true },
),
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import os from "os"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Global } from "@opencode-ai/core/global"
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
async function job() {
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
+87 -61
View File
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { InstructionContext } from "@opencode-ai/core/instruction-context"
@@ -15,6 +17,17 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
}) =>
AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [
[Global.node, Global.layerWith({ config: input.config })],
[Location.node, input.locationServiceLayer],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
])
describe("InstructionContext", () => {
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
Effect.acquireRelease(
@@ -41,19 +54,19 @@ describe("InstructionContext", () => {
const load = SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(FSUtil.defaultLayer),
Effect.provide(Global.layerWith({ config: global })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
instructionLayer({
config: global,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
),
}),
),
)
@@ -107,14 +120,14 @@ describe("InstructionContext", () => {
yield* Effect.promise(() => fs.writeFile(file, ""))
const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(FSUtil.defaultLayer),
Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
instructionLayer({
config: path.join(tmp.path, "global"),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
),
)
@@ -133,14 +146,18 @@ describe("InstructionContext", () => {
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(failingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
instructionLayer({
config: "/global",
filesystemLayer: failingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
@@ -169,14 +186,18 @@ describe("InstructionContext", () => {
}),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(racingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
instructionLayer({
config: "/global",
filesystemLayer: racingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
@@ -208,20 +229,21 @@ describe("InstructionContext", () => {
}),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(observingFS),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
instructionLayer({
config: "/global",
filesystemLayer: observingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
),
),
),
}),
),
)
@@ -241,18 +263,20 @@ describe("InstructionContext", () => {
yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(
Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
instructionLayer({
config: "/global",
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
).pipe(Layer.provide(FSUtil.defaultLayer)),
),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
}),
),
Effect.ensuring(
Effect.sync(() => {
@@ -271,23 +295,25 @@ describe("InstructionContext", () => {
let scanned = false
yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(
Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
instructionLayer({
config: "/global",
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make("/outside") },
{ projectDirectory: AbsolutePath.make("/repo") },
),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer)),
),
Effect.provide(Global.layerWith({ config: "/global" })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }),
),
),
}),
),
)
+5 -20
View File
@@ -1,12 +1,14 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
import { DateTime, Effect, Equal, Hash, Schema } from "effect"
import { Tool } from "@opencode-ai/core/tool/tool"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -30,25 +32,8 @@ import { Reference } from "../src/reference"
import { ToolRegistry } from "../src/tool/registry"
import { ApplicationTools } from "../src/tool/application-tools"
const applicationTools = ApplicationTools.layer
const it = testEffect(
Layer.merge(
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
locationServiceMapLayer.pipe(
Layer.provide(applicationTools),
Layer.provide(
Layer.mergeAll(
Project.defaultLayer,
EventV2.defaultLayer,
Credential.defaultLayer.pipe(Layer.fresh),
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
Global.defaultLayer,
),
),
),
),
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node])),
)
describe("LocationServiceMap", () => {
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -21,7 +22,7 @@ const projectLayer = Layer.succeed(
commit: () => Effect.void,
}),
)
const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
describe("Location", () => {
it.effect("resolves the current project and vcs information", () =>
+1 -1
View File
@@ -88,7 +88,7 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
)
const buildLayer = (state: Ref.Ref<MockState>) =>
// Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
Layer.fresh(
+13 -36
View File
@@ -3,57 +3,34 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const project = Project.layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(ProjectDirectories.defaultLayer),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(locationServiceMapLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(project),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
)
const layer = MoveSession.layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(project),
Layer.provide(SessionStore.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(
layer,
Database.defaultLayer,
EventV2.defaultLayer,
ProjectDirectories.defaultLayer,
project,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
sessions,
AppNodeBuilder.build(
LayerNode.group([
MoveSession.node,
Database.node,
EventV2.node,
ProjectDirectories.node,
Project.node,
SessionProjector.node,
SessionStore.node,
]),
),
)
+3 -10
View File
@@ -1,12 +1,10 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Option } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { Npm } from "@opencode-ai/core/npm"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32"
@@ -21,12 +19,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
)
const npmLayer = (cache: string) =>
Npm.layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(FSUtil.layer),
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
Layer.provide(NodeFileSystem.layer),
)
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
+14 -19
View File
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -11,9 +13,7 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
@@ -23,24 +23,19 @@ const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
SessionStore.node,
PermissionSaved.node,
AgentV2.node,
PermissionV2.node,
]),
[[Location.node, current]],
),
)
const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(Database.defaultLayer),
Layer.provideMerge(SessionStore.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(current),
Layer.provideMerge(sessions),
Layer.provideMerge(SessionExecution.noopLayer),
Layer.provideMerge(PermissionSaved.defaultLayer),
)
const it = testEffect(layer)
function setup(rules: PermissionV2.Ruleset = []) {
return Effect.gen(function* () {
+5 -6
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -10,13 +11,11 @@ import { host } from "./host"
const directory = AbsolutePath.make("/repo/packages/app")
const project = AbsolutePath.make("/repo")
const it = testEffect(
CommandV2.locationLayer.pipe(
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
),
),
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory }, { projectDirectory: project })),
)
const it = testEffect(AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]]))
describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
+42 -27
View File
@@ -1,37 +1,52 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { tempLocationLayer } from "../fixture/location"
export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
Layer.provideMerge(
Layer.mergeAll(
Credential.defaultLayer,
EventV2.defaultLayer,
FetchHttpClient.layer,
FSUtil.defaultLayer,
Global.defaultLayer,
Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
}),
),
RepositoryCache.defaultLayer,
SkillDiscovery.defaultLayer,
Ripgrep.defaultLayer,
tempLocationLayer,
),
),
const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
}),
)
export const PluginTestLayer = AppNodeBuilder.build(
LayerNode.group([
FileSystem.node,
FSUtil.node,
Location.node,
Npm.node,
Credential.node,
EventV2.node,
LayerNodePlatform.httpClient,
PluginV2.node,
AgentV2.node,
AISDK.node,
Catalog.node,
CommandV2.node,
Integration.node,
Reference.node,
SkillV2.node,
]),
[
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
],
)
@@ -6,6 +6,7 @@ import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -17,7 +18,7 @@ import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const fixtureProviderPath = fileURLToPath(fixtureProvider)
const it = testEffect(PluginTestLayer)
const itWithAISDK = testEffect(AISDK.locationLayer.pipe(Layer.provideMerge(PluginTestLayer)))
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -16,8 +17,12 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const events = yield* EventV2.Service
const integration = yield* Integration.Service
yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
yield* OpencodePlugin.effect(host).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provideService(Integration.Service, integration),
)
})
function required<T>(value: T | undefined): T {
+6 -4
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -7,11 +8,12 @@ import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
Policy.locationLayer.pipe(
Layer.provide(
AppNodeBuilder.build(Policy.node, [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
),
],
]),
)
describe("Policy", () => {
+12 -11
View File
@@ -1,6 +1,8 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
@@ -17,11 +19,10 @@ const locationLayer = Layer.succeed(
)
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const it = testEffect(
Pty.layer.pipe(
Layer.provide(configLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
)
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
@@ -205,8 +206,9 @@ describe("pty", () => {
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect(
Pty.layer.pipe(
Layer.provide(
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
[
Config.node,
Layer.mock(Config.Service)({
entries: () =>
Effect.succeed(
@@ -215,10 +217,9 @@ const configuredIt = testEffect(
: [],
),
}),
),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
],
[Location.node, locationLayer],
]),
)
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
+5 -2
View File
@@ -1,12 +1,15 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "../lib/effect"
const it = testEffect(PtyTicket.layer)
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
const it = testEffect(LayerNode.compile(PtyTicket.node))
const itExpiring = testEffect(
LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]),
)
describe("PTY websocket tickets", () => {
it.live("consumes tickets once", () =>
+31 -28
View File
@@ -1,11 +1,15 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Reference } from "@opencode-ai/core/reference"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { SystemContext } from "@opencode-ai/core/system-context/index"
import { it } from "./lib/effect"
const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
describe("ReferenceGuidance", () => {
it.effect("lists available references in the system context", () =>
Effect.gen(function* () {
@@ -17,23 +21,24 @@ describe("ReferenceGuidance", () => {
expect(generation.baseline).toContain("<path>/docs</path>")
expect(generation.baseline).toContain("<description>Use for product documentation</description>")
}).pipe(
Effect.provide(ReferenceGuidance.layer),
Effect.provide(
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
new Reference.Info({
name: "docs",
path: AbsolutePath.make("/docs"),
description: "Use for product documentation",
source: Reference.LocalSource.make({
type: "local",
guidanceLayer(
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
new Reference.Info({
name: "docs",
path: AbsolutePath.make("/docs"),
description: "Use for product documentation",
source: Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make("/docs"),
description: "Use for product documentation",
}),
}),
}),
]),
}),
]),
}),
),
),
),
)
@@ -43,10 +48,7 @@ describe("ReferenceGuidance", () => {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* SystemContext.initialize(yield* guidance.load())
expect(generation.baseline).toBe("")
}).pipe(
Effect.provide(ReferenceGuidance.layer),
Effect.provide(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })),
),
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
)
it.effect("omits references without descriptions", () =>
@@ -55,18 +57,19 @@ describe("ReferenceGuidance", () => {
const generation = yield* SystemContext.initialize(yield* guidance.load())
expect(generation.baseline).toBe("")
}).pipe(
Effect.provide(ReferenceGuidance.layer),
Effect.provide(
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
new Reference.Info({
name: "docs",
path: AbsolutePath.make("/docs"),
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
}),
]),
}),
guidanceLayer(
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
new Reference.Info({
name: "docs",
path: AbsolutePath.make("/docs"),
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
}),
]),
}),
),
),
),
)
+13 -21
View File
@@ -4,6 +4,8 @@ import { Effect, Layer, Stream } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
@@ -13,7 +15,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -34,23 +35,13 @@ const projects = Layer.succeed(
commit: () => Effect.void,
}),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(projects),
Layer.provide(SessionExecution.noopLayer),
)
const it = testEffect(
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
projects,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
SessionExecution.noopLayer,
sessions,
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
[
[ProjectV2.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
@@ -248,9 +239,10 @@ describe("SessionV2.create", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase))
const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase))
const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase))
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]),
[[Database.node, targetDatabase]],
)
yield* Effect.gen(function* () {
const db = (yield* Database.Service).db
@@ -298,7 +290,7 @@ describe("SessionV2.create", () => {
[1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)],
[2, EventV2.versionedType(SessionEvent.Prompted.type, 1)],
])
}).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore))))
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
)
+8 -17
View File
@@ -1,9 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -22,23 +23,13 @@ const projects = Layer.succeed(
commit: () => Effect.void,
}),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(projects),
Layer.provide(SessionExecution.noopLayer),
)
const it = testEffect(
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
projects,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
SessionExecution.noopLayer,
sessions,
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
[
[ProjectV2.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
+5 -16
View File
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -9,7 +11,6 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -41,22 +42,10 @@ const execution = Layer.succeed(
}),
}),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(execution),
)
const it = testEffect(
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
execution,
sessions,
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
[[SessionExecution.node, execution]],
),
)
const sessionID = SessionV2.ID.make("ses_prompt_test")
@@ -3,6 +3,9 @@ import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -12,7 +15,6 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -22,6 +24,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
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"
@@ -57,8 +60,6 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const agents = AgentV2.layer
const model = OpenAIChat.route
.with({
endpoint: { baseURL: "https://api.openai.com/v1" },
@@ -67,26 +68,22 @@ const model = OpenAIChat.route
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContext = SystemContextRegistry.layer
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
const systemContext = AppNodeBuilder.build(SystemContextRegistry.node)
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(Snapshot.noopLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(client),
Layer.provide(registry),
Layer.provide(models),
Layer.provide(systemContext),
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(config),
)
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
[Config.node, config],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
@@ -101,34 +98,38 @@ const execution = Layer.effect(
interrupt: coordinator.interrupt,
})
}),
).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe(
Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(execution),
)
).pipe(Layer.provide(runnerLayer))
const it = testEffect(
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
executor,
client,
permission,
agents,
registry,
models,
systemContext,
location,
skillGuidance,
config,
runner,
execution,
sessions,
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
ToolRegistry.node,
SessionRunnerModel.node,
SystemContextRegistry.node,
SkillGuidance.node,
ReferenceGuidance.node,
Config.node,
Snapshot.node,
SessionRunnerLLM.node,
SessionV2.node,
]),
[
[LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[SessionRunnerModel.node, models],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
[Config.node, config],
[Snapshot.node, Snapshot.noopLayer],
[SessionExecution.node, execution],
],
),
)
const sessionID = SessionV2.ID.make("ses_runner_recorded")
@@ -1,6 +1,8 @@
import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool"
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 { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
@@ -27,9 +29,13 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
)
},
})
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
const it = testEffect(registry)
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
const it = testEffect(registryLayer)
const integrated = testEffect(
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node]), [
[ToolOutputStore.node, outputStore],
]),
)
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"),
+53 -57
View File
@@ -11,6 +11,10 @@ import {
} from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Database } from "@opencode-ai/core/database/database"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql"
@@ -19,7 +23,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { QuestionV2 } from "@opencode-ai/core/question"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -33,7 +36,6 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
@@ -57,7 +59,6 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream }
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const requests: LLMRequest[] = []
let response: LLMEvent[] = []
let responses: LLMEvent[][] | undefined
@@ -120,13 +121,6 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const applications = ApplicationTools.layer
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const agents = AgentV2.layer
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
registry.register({
@@ -156,7 +150,8 @@ const echo = Layer.effectDiscard(
}),
}),
),
).pipe(Layer.provide(registry))
)
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
let modelResolveHook = Effect.void
let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
@@ -196,8 +191,7 @@ const systemContext = Layer.effectDiscard(
}),
),
),
).pipe(Layer.provideMerge(SystemContextRegistry.layer))
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node)))
const skillGuidance = Layer.mock(SkillGuidance.Service, {
load: (agent) =>
Effect.succeed(
@@ -231,21 +225,17 @@ const config = Layer.succeed(
]),
}),
)
const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(Snapshot.noopLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(client),
Layer.provide(registry),
Layer.provide(models),
Layer.provide(systemContext),
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(config),
)
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
[PermissionV2.node, permission],
[Config.node, config],
])
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
@@ -260,36 +250,42 @@ const execution = Layer.effect(
interrupt: coordinator.interrupt,
})
}),
).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe(
Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(execution),
)
).pipe(Layer.provide(runnerLayer))
const it = testEffect(
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
questions,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
client,
permission,
applications,
agents,
registry,
echo,
models,
systemContext,
location,
skillGuidance,
config,
runner,
execution,
sessions,
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
QuestionV2.node,
SessionProjector.node,
SessionStore.node,
ApplicationTools.node,
AgentV2.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
echoNode,
SessionRunnerModel.node,
SystemContextRegistry.node,
SkillGuidance.node,
ReferenceGuidance.node,
Config.node,
Snapshot.node,
SessionRunnerLLM.node,
SessionExecution.node,
SessionV2.node,
]),
[
[LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission],
[SessionRunnerModel.node, models],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
[Snapshot.node, Snapshot.noopLayer],
[SessionExecution.node, execution],
[Config.node, config],
],
),
)
const sessionID = SessionV2.ID.make("ses_runner_test")
+4 -1
View File
@@ -2,6 +2,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SystemContext } from "@opencode-ai/core/system-context"
@@ -28,7 +29,9 @@ const denied = SkillV2.Info.make({
})
const layer = (list: () => SkillV2.Info[]) =>
SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })))
AppNodeBuilder.build(SkillGuidance.node, [
[SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })],
])
describe("SkillGuidance", () => {
it.effect("renders described agent skills and reconciles the complete available list", () => {
+6 -17
View File
@@ -3,12 +3,9 @@ import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Hash } from "@opencode-ai/core/util/hash"
@@ -118,9 +115,7 @@ describe("Snapshot", () => {
return (yield* Location.Service).project.id
}).pipe(
Effect.provide(
Location.layer(Location.Ref.make({ directory: AbsolutePath.make(project) })).pipe(
Layer.provide(Project.defaultLayer),
),
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
),
)
expect(
@@ -172,16 +167,10 @@ describe("Snapshot", () => {
})
function snapshotLayer(data: string, directory: string) {
const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
Layer.provide(Project.defaultLayer),
)
return Snapshot.layer.pipe(
Layer.provide(location),
Layer.provide(Config.locationLayer.pipe(Layer.provide(location))),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Global.layerWith({ data, config: path.join(data, "config") })),
)
return AppNodeBuilder.build(Snapshot.node, [
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))],
[Global.node, Global.layerWith({ data, config: path.join(data, "config") })],
])
}
function read(file: string) {
@@ -1,5 +1,6 @@
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"
@@ -17,7 +18,7 @@ const entry = (key: string, text: string, sourceKey = key) => ({
),
})
const it = testEffect(SystemContextRegistry.layer)
const it = testEffect(AppNodeBuilder.build(SystemContextRegistry.node))
describe("SystemContextRegistry", () => {
it.effect("loads empty system context when there are no entries", () =>
+23 -12
View File
@@ -2,6 +2,8 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@@ -10,6 +12,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
@@ -81,26 +84,34 @@ const filesystem = Layer.effect(
},
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const patch = ApplyPatchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
Layer.provide(filesystem),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, patch)))
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
ToolRegistry.node,
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
ApplyPatchTool.node,
]),
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
),
)
}
const call = (patchText: string, id = "call-apply-patch") => ({
+18 -13
View File
@@ -6,6 +6,8 @@ import { Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -14,6 +16,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -99,24 +102,26 @@ const withTool = <A, E, R>(
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
processLayer: Layer.Layer<AppProcess.Service> = appProcess,
) => {
const filesystem = FSUtil.defaultLayer
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const bash = BashTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(mutation),
Layer.provide(filesystem),
Layer.provide(processLayer),
Layer.provide(config),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, BashTool.node]),
[
[Location.node, activeLocation],
[PermissionV2.node, permission],
[AppProcess.node, processLayer],
[Config.node, config],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
),
)
}
const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
@@ -231,7 +236,7 @@ describe("BashTool", () => {
return withTool(
tmp.path,
(registry) => settleTool(registry, call({ command: "printf core-bash" })),
AppProcess.defaultLayer,
LayerNode.compile(AppProcess.node),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
+23 -12
View File
@@ -3,6 +3,8 @@ import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@@ -11,6 +13,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { EditTool } from "@opencode-ai/core/tool/edit"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
@@ -71,26 +74,34 @@ const filesystem = Layer.effect(
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const edit = EditTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
Layer.provide(filesystem),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit)))
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
ToolRegistry.node,
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
EditTool.node,
]),
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
),
)
}
const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
+7 -5
View File
@@ -31,9 +31,6 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
const question = Layer.succeed(
QuestionV2.Service,
QuestionV2.Service.of({
@@ -46,8 +43,13 @@ const question = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question))
const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
[PermissionV2.node, permission],
[QuestionV2.node, question],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
)
describe("QuestionTool", () => {
it.effect("omits a denied built-in question and terminally settles a stale call", () =>
+22 -29
View File
@@ -3,6 +3,8 @@ import path from "path"
import { Effect, Exit, Layer, PlatformError } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@@ -14,6 +16,7 @@ import { Global } from "@opencode-ai/core/global"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect"
@@ -69,9 +72,8 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
const image = Image.layer.pipe(Layer.provide(config))
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
const testFileSystem = Layer.effect(
FSUtil.Service,
FSUtil.Service.use((fs) =>
@@ -92,11 +94,10 @@ const testFileSystem = Layer.effect(
}),
),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
const infrastructure = Layer.mergeAll(
testFileSystem,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
Global.layerWith({ data: Global.Path.data }),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
)
const mutation = Layer.succeed(
LocationMutation.Service,
@@ -128,28 +129,20 @@ const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const read = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(image),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
)
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [
[ReadToolFileSystem.node, reader],
[PermissionV2.node, permission],
[Config.node, config],
[Image.node, imageLayer],
[LocationMutation.node, mutation],
[FSUtil.node, testFileSystem],
[Location.node, locationLayer],
[Global.node, Global.layerWith({ data: Global.Path.data })],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
const it = testEffect(readLayer(imageLayer))
const itWithoutResizer = testEffect(readLayer(unavailableImage))
const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => {
+8 -11
View File
@@ -4,7 +4,6 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
@@ -66,16 +65,14 @@ describe("SkillTool", () => {
list: () => Effect.succeed(current),
}),
)
const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
const tool = SkillTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(LayerNode.compile(FSUtil.node)),
Layer.provide(skills),
const skillToolLayer = AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]),
[
[PermissionV2.node, permission],
[SkillV2.node, skills],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
)
const layer = Layer.mergeAll(permission, skills, registry, tool)
return yield* Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
@@ -144,7 +141,7 @@ describe("SkillTool", () => {
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
}).pipe(Effect.provide(layer))
}).pipe(Effect.provide(skillToolLayer))
}),
),
),
+17 -7
View File
@@ -1,6 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
@@ -11,6 +13,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo"
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
@@ -32,14 +35,21 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const tool = TodoWriteTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(SessionTodo.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer, permission, registry, tool),
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
SessionTodo.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
TodoWriteTool.node,
]),
[
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
)
const setup = Effect.gen(function* () {
+13 -10
View File
@@ -1,11 +1,15 @@
import { describe, expect, test } from "bun:test"
import { Duration, Effect, Fiber, Layer, Schema } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
@@ -35,15 +39,14 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http))
const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch))
const fetchWebfetch = WebFetchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(FetchHttpClient.layer),
)
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch))
const toolLayer = (replacements: LayerNode.Replacements = []) =>
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
...replacements,
])
const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]]))
const live = testEffect(toolLayer())
const reset = () => {
requests.length = 0
+14 -7
View File
@@ -1,10 +1,14 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
@@ -99,7 +103,6 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const websearchConfig = Layer.succeed(
WebSearchTool.ConfigService,
WebSearchTool.ConfigService.of({
@@ -120,13 +123,17 @@ const websearchConfig = Layer.succeed(
},
}),
)
const websearch = WebSearchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(http),
Layer.provide(websearchConfig),
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]),
[
[PermissionV2.node, permission],
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
)
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch))
describe("WebSearchTool registration", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
+23 -11
View File
@@ -4,6 +4,8 @@ import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
@@ -11,6 +13,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { WriteTool } from "@opencode-ai/core/tool/write"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
@@ -55,25 +58,34 @@ const filesystem = Layer.effect(
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const write = WriteTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
ToolRegistry.node,
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
WriteTool.node,
]),
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
),
)
}
const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
@@ -9,6 +9,7 @@ import {
Usage,
type CacheHint,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
@@ -21,6 +22,7 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share
import { isContextOverflow } from "../provider-error"
import * as Cache from "./utils/cache"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "anthropic-messages"
@@ -256,10 +258,10 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
input_schema: inputSchema,
cache_control: cacheControl(breakpoints, tool.cache),
})
@@ -504,6 +506,8 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
@@ -511,7 +515,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
const tools =
request.tools.length === 0 || request.toolChoice?.type === "none"
? undefined
: request.tools.map((tool) => lowerTool(breakpoints, tool))
: request.tools.map((tool) =>
lowerTool(
breakpoints,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
)
const system =
request.system.length === 0
? undefined
@@ -533,7 +543,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools,
tool_choice: toolChoice,
stream: true as const,
max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096,
max_tokens: generation?.maxTokens ?? outputLimit,
temperature: generation?.temperature,
top_p: generation?.topP,
top_k: generation?.topK,
+12 -5
View File
@@ -7,7 +7,9 @@ import {
Usage,
type CacheHint,
type FinishReason,
type JsonSchema,
type LLMRequest,
type ModelToolSchemaCompatibility,
type ProviderMetadata,
type ReasoningPart,
type ToolCallPart,
@@ -21,6 +23,7 @@ import { BedrockAuth } from "./utils/bedrock-auth"
import { BedrockCache } from "./utils/bedrock-cache"
import { BedrockMedia } from "./utils/bedrock-media"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "bedrock-converse"
@@ -205,18 +208,22 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
// =============================================================================
// Request Lowering
// =============================================================================
const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
toolSpec: {
name: tool.name,
description: tool.description,
inputSchema: { json: tool.inputSchema },
inputSchema: { json: inputSchema },
},
})
const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
const lowerTools = (
compatibility: ModelToolSchemaCompatibility | undefined,
breakpoints: BedrockCache.Breakpoints,
tools: ReadonlyArray<ToolDefinition>,
): BedrockTool[] => {
const result: BedrockTool[] = []
for (const tool of tools) {
result.push(lowerToolSpec(tool))
result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility)))
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
if (cachePoint) result.push(cachePoint)
}
@@ -386,7 +393,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
const breakpoints = BedrockCache.breakpoints()
const toolConfig =
request.tools.length > 0 && request.toolChoice?.type !== "none"
? { tools: lowerTools(breakpoints, request.tools), toolChoice }
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
: undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)
+14 -3
View File
@@ -8,6 +8,7 @@ import {
LLMEvent,
Usage,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
@@ -19,6 +20,7 @@ import {
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
const ADAPTER = "gemini"
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
@@ -166,10 +168,10 @@ interface ParserState {
// =============================================================================
// Request Lowering
// =============================================================================
const lowerTool = (tool: ToolDefinition) => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({
name: tool.name,
description: tool.description,
parameters: GeminiToolSchema.convert(tool.inputSchema),
parameters: GeminiToolSchema.convert(inputSchema),
})
const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -300,6 +302,7 @@ const thinkingConfig = (request: LLMRequest) => {
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
maxOutputTokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -313,7 +316,15 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
contents: yield* lowerMessages(request),
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined,
tools: toolsEnabled
? [
{
functionDeclarations: request.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
},
]
: undefined,
toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
? generationConfig
+11 -3
View File
@@ -8,6 +8,7 @@ import {
LLMEvent,
Usage,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ReasoningPart,
@@ -19,6 +20,7 @@ import {
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-chat"
@@ -174,12 +176,12 @@ const invalid = ProviderShared.invalidRequest
// Lowering is the only place that knows how common LLM messages map onto the
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`.
const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema),
parameters: ToolSchemaProjection.openAI(inputSchema),
},
})
@@ -343,10 +345,16 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`.
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
messages: yield* lowerMessages(request),
tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool),
tools:
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
stream_options: { include_usage: true },
+11 -3
View File
@@ -8,6 +8,7 @@ import {
LLMEvent,
Usage,
type FinishReason,
type JsonSchema,
type LLMRequest,
type ProviderMetadata,
type ReasoningPart,
@@ -21,6 +22,7 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share
import { isContextOverflow } from "../provider-error"
import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-responses"
@@ -254,11 +256,11 @@ const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema),
parameters: ToolSchemaProjection.openAI(inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false,
})
@@ -476,10 +478,16 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const generation = request.generation
const options = yield* lowerOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request),
tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool),
tools:
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
max_output_tokens: generation?.maxTokens,
+1 -34
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, JsonSchema, Schema, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
@@ -24,39 +24,6 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
/** OpenAI function schemas require one flat object at the top level. */
export const openAiToolInputSchema = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
const flattened =
variants.length === 0
? { ...schema, type: "object" }
: {
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
type: "object",
properties: variants.reduce(
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
{},
),
additionalProperties: false,
}
const normalized = removeNullSchemas(flattened)
return isRecord(normalized) ? normalized : { type: "object" }
}
const removeNullSchemas = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(removeNullSchemas)
if (!isRecord(value)) return value
const fields = Object.fromEntries(
Object.entries(value)
.filter(([key]) => key !== "anyOf")
.map(([key, field]) => [key, removeNullSchemas(field)]),
)
if (!Array.isArray(value.anyOf)) return fields
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
return { ...fields, anyOf: variants }
}
/**
* Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here
@@ -1,4 +1,4 @@
import { ProviderShared } from "../shared"
import { isRecord } from "../../utils/record"
// Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a
// handful of common JSON Schema shapes. Keep this projection isolated so the
@@ -20,8 +20,6 @@ const SCHEMA_INTENT_KEYS = [
"else",
]
const isRecord = ProviderShared.isRecord
const hasCombiner = (schema: unknown) =>
isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf))
@@ -0,0 +1,86 @@
import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema"
import { isRecord } from "../../utils/record"
import { GeminiToolSchema } from "./gemini-tool-schema"
const removeNullSchemas = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(removeNullSchemas)
if (!isRecord(value)) return value
const fields = Object.fromEntries(
Object.entries(value)
.filter(([key]) => key !== "anyOf")
.map(([key, field]) => [key, removeNullSchemas(field)]),
)
if (!Array.isArray(value.anyOf)) return fields
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
return { ...fields, anyOf: variants }
}
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
const projected = items.map(moonshotNode)
if (projected.length === 0) return {}
if (projected.length === 1) return projected[0]
return { anyOf: projected }
}
const moonshotNode = (schema: unknown): unknown => {
if (Array.isArray(schema)) return schema.map(moonshotNode)
if (!isRecord(schema)) return schema
if (typeof schema.$ref === "string") return { $ref: schema.$ref }
return Object.fromEntries(
Object.entries(schema).flatMap(([key, value]) => {
if (key === "items" && Array.isArray(value)) return [[key, tupleItemsSchema(value)]]
if (key === "prefixItems") {
if ("items" in schema) return []
return [["items", tupleItemsSchema(Array.isArray(value) ? value : [])]]
}
if (key === "unevaluatedItems") return []
return [[key, moonshotNode(value)]]
}),
)
}
const moonshot = (schema: JsonSchema): JsonSchema => {
const projected = moonshotNode(schema)
return isRecord(projected) ? projected : {}
}
const openAI = (schema: JsonSchema): JsonSchema => {
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
const flattened =
variants.length === 0
? { ...schema, type: "object" }
: {
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
type: "object",
properties: variants.reduce(
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
{},
),
additionalProperties: false,
}
const normalized = removeNullSchemas(flattened)
return isRecord(normalized) ? normalized : { type: "object" }
}
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
const modelCompatibility = (
schema: JsonSchema,
compatibility: ModelToolSchemaCompatibility | undefined,
): JsonSchema => {
if (compatibility === undefined) return schema
switch (compatibility) {
case "gemini":
return gemini(schema)
case "moonshot":
return moonshot(schema)
}
}
export const ToolSchemaProjection = {
gemini,
modelCompatibility,
moonshot,
openAI,
} as const
+1 -2
View File
@@ -1,7 +1,6 @@
import type { RouteDefaultsInput } from "./route/client"
import type { Model, ModelID, ProviderID } from "./schema"
export type ModelOptions = RouteDefaultsInput
export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
/**
* Advanced structural provider definition helper. Built-in providers should
+13 -6
View File
@@ -164,13 +164,20 @@ export interface GenerateMethod {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const resolveRequestOptions = (request: LLMRequest) =>
LLMRequest.update(request, {
generation:
mergeGenerationOptions(request.model.route.defaults.generation, request.generation) ?? new GenerationOptions({}),
providerOptions: mergeProviderOptions(request.model.route.defaults.providerOptions, request.providerOptions),
http: mergeHttpOptions(request.model.route.defaults.http, request.http),
const resolveRequestOptions = (request: LLMRequest) => {
const routeDefaults = request.model.route.defaults
const modelDefaults = request.model.defaults
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
return LLMRequest.update(request, {
generation: generation ?? new GenerationOptions({}),
providerOptions: mergeProviderOptions(
routeDefaults.providerOptions,
modelDefaults?.providerOptions,
request.providerOptions,
),
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
})
}
export interface MakeInput<Body, Frame, Event, State> {
/** Route id used in diagnostics and prepared request metadata. */
+47
View File
@@ -28,9 +28,56 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString()
}
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
"content",
"contents",
"frequencyPenalty",
"frequency_penalty",
"generationConfig",
"inferenceConfig",
"input",
"maxTokens",
"max_tokens",
"messages",
"model",
"presencePenalty",
"presence_penalty",
"responseFormat",
"response_format",
"seed",
"stop",
"stopSequences",
"stop_sequences",
"stream",
"streamOptions",
"stream_options",
"system",
"systemInstruction",
"system_instruction",
"temperature",
"thinking",
"toolChoice",
"toolConfig",
"tool_choice",
"tool_config",
"tools",
"topK",
"topP",
"top_k",
"top_p",
])
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
Effect.gen(function* () {
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
if (forbiddenKeys.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
)
if (ProviderShared.isRecord(body)) {
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
+56 -1
View File
@@ -134,15 +134,62 @@ export namespace ModelLimits {
input instanceof ModelLimits ? input : new ModelLimits(input ?? {})
}
export class ModelDefaults extends Schema.Class<ModelDefaults>("LLM.ModelDefaults")({
limits: Schema.optional(ModelLimits),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
}) {}
export namespace ModelDefaults {
export type Input =
| ModelDefaults
| {
readonly limits?: ModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
}
/** Normalize selected-model request defaults without applying precedence. */
export const make = (input: Input) => {
if (input instanceof ModelDefaults) return input
return new ModelDefaults({
limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
}
}
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
}) {}
export namespace ModelCompatibility {
export type Input = ModelCompatibility | ConstructorParameters<typeof ModelCompatibility>[0]
/** Normalize model/upstream compatibility metadata without projecting requests. */
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
}
export class Model {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly defaults?: ModelDefaults
readonly compatibility?: ModelCompatibility
constructor(input: Model.ConstructorInput) {
this.id = input.id
this.provider = input.provider
this.route = input.route
this.defaults = input.defaults
this.compatibility = input.compatibility
}
static make(input: Model.Input) {
@@ -150,6 +197,8 @@ export class Model {
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults),
compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility),
})
}
@@ -158,6 +207,8 @@ export class Model {
id: model.id,
provider: model.provider,
route: model.route,
defaults: model.defaults,
compatibility: model.compatibility,
}
}
@@ -175,11 +226,15 @@ export namespace Model {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly defaults?: ModelDefaults
readonly compatibility?: ModelCompatibility
}
export type Input = Omit<ConstructorInput, "id" | "provider"> & {
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly defaults?: ModelDefaults.Input
readonly compatibility?: ModelCompatibility.Input
}
}
+8 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM } from "../src"
import { LLM, LLMResponse } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { Model } from "../src/schema"
import { testEffect } from "./lib/effect"
@@ -112,9 +112,15 @@ describe("llm route", () => {
const llm = yield* LLMClient.Service
const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect))
const response = yield* llm.generate(request)
const reduced = LLMResponse.fromEvents(events)
expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"])
expect(reduced).toBeDefined()
if (!reduced) throw new Error("stream reducer did not produce a completed response")
expect(response.events).toEqual(events)
expect(response.message).toEqual(reduced.message)
expect(response.usage).toEqual(reduced.usage)
expect(response.finishReason).toEqual(reduced.finishReason)
expect(response.message.content).toEqual([{ type: "text", text: 'echo:{"body":"hello"}' }])
}),
)
+34 -2
View File
@@ -90,15 +90,47 @@ describe("llm constructors", () => {
provider: "fake",
route: chatRoute,
})
const updated = Model.update(base, { route: responsesRoute })
const updated = Model.update(base, {
route: responsesRoute,
defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini" },
})
const updatedInput = Model.input(updated)
expect(updated).toBeInstanceOf(Model)
expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute)
expect(String(Model.input(updated).provider)).toBe("fake")
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake")
expect(Model.update(updated, {})).toBe(updated)
})
test("carries model defaults and compatibility through route model selection", () => {
const model = chatRoute.model({
id: "kimi-k2",
defaults: {
limits: { context: 128_000, output: 8_192 },
generation: { maxTokens: 1_024, stop: ["END"] },
providerOptions: { openai: { parallelToolCalls: false } },
http: { body: { extra_body: true } },
},
compatibility: { toolSchema: "moonshot" },
})
const request = LLM.request({ model, prompt: "Say hello." })
expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
expect(request.model.defaults?.providerOptions).toEqual({ openai: { parallelToolCalls: false } })
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" })
expect(request.generation).toBeUndefined()
expect(request.providerOptions).toBeUndefined()
expect(request.http).toBeUndefined()
})
test("builds tool choices from names and tools", () => {
const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
import { Auth, LLMClient } from "../src/route"
import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
import { deltaChunk } from "./lib/openai-chunks"
import { sseEvents } from "./lib/sse"
const TargetJson = Schema.fromJsonString(Schema.Unknown)
const decodeJson = Schema.decodeUnknownSync(TargetJson)
describe("request option precedence", () => {
test("deep-merges provider option records and replaces arrays, primitives, and null", () => {
const merged = mergeProviderOptions(
{
openai: {
include: ["route"],
metadata: { route: true, shared: "route" },
nullable: "route",
primitive: "route",
},
},
{
openai: {
include: ["model"],
metadata: { model: true, shared: "model" },
nullable: null,
primitive: "model",
},
},
{ openai: { metadata: { request: true }, primitive: false } },
)
expect(merged).toEqual({
openai: {
include: ["model"],
metadata: { route: true, model: true, request: true, shared: "model" },
nullable: null,
primitive: false,
},
})
})
it.effect("prepares bodies with route defaults, model defaults, and call options in order", () =>
Effect.gen(function* () {
const route = OpenAIChat.route.with({
endpoint: { baseURL: "https://api.openai.test/v1/" },
auth: Auth.bearer("test"),
generation: { maxTokens: 10, temperature: 1, stop: ["route"] },
providerOptions: { openai: { store: false, reasoningEffort: "low" } },
})
const model = route.model({
id: "gpt-4o-mini",
defaults: {
generation: { maxTokens: 20, temperature: 0.5, frequencyPenalty: 0.25, stop: ["model"] },
providerOptions: { openai: { reasoningEffort: "medium" } },
},
})
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
prompt: "Say hello.",
generation: { maxTokens: 30, topP: 0.9, stop: ["request"] },
providerOptions: { openai: { store: true } },
}),
)
expect(prepared.body).toMatchObject({
model: "gpt-4o-mini",
stream: true,
max_tokens: 30,
temperature: 0.5,
top_p: 0.9,
frequency_penalty: 0.25,
store: true,
reasoning_effort: "medium",
})
expect(prepared.body.stop).toEqual(["request"])
}),
)
it.effect("applies model HTTP defaults before request HTTP overlays", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({
endpoint: { baseURL: "https://api.openai.test/v1/" },
auth: Auth.bearer("fresh-key"),
http: {
body: { metadata: { route: true, shared: "route" }, value: "route" },
headers: { "x-route": "route", "x-shared": "route" },
query: { route: "1", shared: "route" },
},
})
.model({
id: "gpt-4o-mini",
defaults: {
http: {
body: { metadata: { model: true, shared: "model" }, value: "model" },
headers: { "x-model": "model", "x-shared": "model" },
query: { model: "1", shared: "model" },
},
},
}),
prompt: "Say hello.",
http: {
body: { metadata: { request: true }, value: null },
headers: { "x-request": "request" },
query: { request: "1" },
},
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://api.openai.test/v1/chat/completions?route=1&shared=model&model=1&request=1")
expect(web.headers.get("authorization")).toBe("Bearer fresh-key")
expect(web.headers.get("x-route")).toBe("route")
expect(web.headers.get("x-model")).toBe("model")
expect(web.headers.get("x-request")).toBe("request")
expect(web.headers.get("x-shared")).toBe("model")
expect(decodeJson(input.text)).toMatchObject({
metadata: { route: true, model: true, request: true, shared: "model" },
value: null,
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const error = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(Effect.flip)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
})
}),
)
it.effect("uses model output limits after route limits and before call maxTokens", () =>
Effect.gen(function* () {
const route = AnthropicMessages.route.with({
endpoint: { baseURL: "https://api.anthropic.test/v1/" },
auth: Auth.header("x-api-key", "test"),
limits: { output: 128 },
})
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
const withoutMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, prompt: "Say hello.", cache: "none" }),
)
const withMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
)
expect(withoutMaxTokens.body.max_tokens).toBe(64)
expect(withMaxTokens.body.max_tokens).toBe(32)
}),
)
})
@@ -395,6 +395,10 @@ describe("Anthropic Messages route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
expect(response.message.content).toEqual([
{ type: "text", text: "Hello!" },
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: "stop",
@@ -778,6 +778,11 @@ describe("OpenAI Responses route", () => {
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
])
}),
)
+41 -3
View File
@@ -2,10 +2,11 @@ import { describe, expect, test } from "bun:test"
import { LLMEvent, LLMResponse } from "../src"
const reduce = (events: ReadonlyArray<LLMEvent>) => events.reduce(LLMResponse.reduce, LLMResponse.empty())
const finishEvents = (events: ReadonlyArray<LLMEvent>) => events.filter(LLMEvent.is.finish)
describe("LLMResponse reducer", () => {
test("assembles interleaved reasoning and text with end metadata", () => {
const response = LLMResponse.fromEvents([
const events = [
LLMEvent.reasoningStart({ id: "r1" }),
LLMEvent.reasoningDelta({ id: "r1", text: "I should " }),
LLMEvent.textStart({ id: "t1" }),
@@ -14,10 +15,23 @@ describe("LLMResponse reducer", () => {
LLMEvent.textDelta({ id: "t1", text: "Answer" }),
LLMEvent.textEnd({ id: "t1" }),
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }),
])
]
const response = LLMResponse.fromEvents(events)
expect(response?.finishReason).toBe("stop")
expect(response?.usage).toMatchObject({ outputTokens: 5 })
expect(response?.events).toEqual(events)
expect(response?.events.map((event) => event.type)).toEqual([
"reasoning-start",
"reasoning-delta",
"text-start",
"reasoning-delta",
"reasoning-end",
"text-delta",
"text-end",
"finish",
])
expect(finishEvents(response?.events ?? [])).toHaveLength(1)
expect(response?.message.content).toEqual([
{
type: "reasoning",
@@ -26,7 +40,6 @@ describe("LLMResponse reducer", () => {
},
{ type: "text", text: "Answer" },
])
expect(response?.events).toHaveLength(8)
})
test("preserves partial content without completing a failed stream", () => {
@@ -36,6 +49,31 @@ describe("LLMResponse reducer", () => {
expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
})
test("does not complete ended content without a terminal finish", () => {
const state = reduce([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textDelta({ id: "t1", text: "partial" }),
LLMEvent.textEnd({ id: "t1" }),
])
expect(LLMResponse.complete(state)).toBeUndefined()
expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
})
test("uses terminal usage when present and keeps prior usage when finish omits it", () => {
const withFinishUsage = LLMResponse.fromEvents([
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }),
])
const withoutFinishUsage = LLMResponse.fromEvents([
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: "stop" }),
])
expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 })
expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 })
})
test("assembles tool-call content only after the completed tool call event", () => {
const pending = reduce([
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../src"
import { OpenAIChat } from "../src/protocols"
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema"
import { Auth, LLMClient } from "../src/route"
import { it } from "./lib/effect"
describe("tool schema projections", () => {
test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => {
expect(
ToolSchemaProjection.moonshot({
type: "object",
properties: {
linked: { $ref: "#/$defs/Linked", description: "drop me" },
tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] },
prefixTuple: { type: "array", prefixItems: [{ type: "boolean" }, { type: "string" }] },
},
}),
).toEqual({
type: "object",
properties: {
linked: { $ref: "#/$defs/Linked" },
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
prefixTuple: { type: "array", items: { anyOf: [{ type: "boolean" }, { type: "string" }] } },
},
})
})
test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => {
expect(
ToolSchemaProjection.gemini({
type: "object",
required: ["status", "missing"],
properties: {
status: { type: "integer", enum: [1, 2] },
tags: { type: "array" },
name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] },
},
}),
).toEqual({
type: "object",
required: ["status"],
properties: {
status: { type: "string", enum: ["1", "2"] },
tags: { type: "array", items: { type: "string" } },
name: { type: "string" },
},
})
})
test("openai keeps one flat object top-level schema", () => {
expect(
ToolSchemaProjection.openAI({
anyOf: [
{
type: "object",
properties: {
path: { type: "string" },
maybe: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
{ type: "object", properties: { resource: { type: "string" } } },
],
}),
).toEqual({
type: "object",
properties: {
path: { type: "string" },
maybe: { type: "string" },
resource: { type: "string" },
},
additionalProperties: false,
})
})
it.effect("applies model compatibility before protocol projection", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } })
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
prompt: "Use the tool.",
tools: [
{
name: "lookup",
description: "Lookup data.",
inputSchema: {
type: "object",
anyOf: [
{
type: "object",
properties: {
tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] },
linked: { $ref: "#/$defs/Linked", description: "drop me" },
},
},
],
},
},
],
}),
)
expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
type: "object",
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
additionalProperties: false,
})
}),
)
})
+2 -1
View File
@@ -47,7 +47,6 @@
"@typescript/native-preview": "catalog:",
"drizzle-orm": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2"
},
@@ -109,6 +108,7 @@
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"acorn": "8.15.0",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
@@ -142,6 +142,7 @@
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"typescript": "catalog:",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.2",
"vscode-jsonrpc": "8.2.1",
+11 -1
View File
@@ -1,7 +1,9 @@
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
@@ -204,7 +206,15 @@ export const defaultLayer = layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])),
)
export const loaderNode = LayerNode.make({
service: Loader,
layer: loaderLayer,
deps: [Provider.node, Agent.node, Command.node, InstanceStore.node],
})
export const node = LayerNode.make({ service: Service, layer, deps: [loaderNode] })
export * as Directory from "./directory"
+3
View File
@@ -1,5 +1,6 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Context, Effect, Layer, Ref } from "effect"
@@ -202,6 +203,8 @@ export const layer = Layer.effect(
export const defaultLayer = layer
export const node = LayerNode.make({ service: Service, layer, deps: [] })
function makeSession(input: StoreInput): Info {
return {
id: input.id,
+14 -1
View File
@@ -1,7 +1,10 @@
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
@@ -226,7 +229,17 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(contextLimitLoaderLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])),
)
export const messageLoaderNode = LayerNode.unbound(MessageLoader, Node.tags.values.global)
export const contextLimitLoaderNode = makeGlobalNode({
service: ContextLimitLoader,
layer: contextLimitLoaderLayer,
deps: [Provider.node, InstanceStore.node],
})
export const node = makeGlobalNode({ service: Service, layer, deps: [messageLoaderNode, contextLimitLoaderNode] })
export * as UsageService from "./usage"
@@ -601,7 +601,11 @@ export const layer = Layer.effect(
}),
fallback: "",
response: "text",
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
}).pipe(
Effect.provide(
LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]),
),
)
: ""
if (sourcePatch) {
@@ -617,7 +621,11 @@ export const layer = Layer.effect(
body: HttpBody.jsonUnsafe({ patch: sourcePatch }),
}),
fallback: { applied: false },
}).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))))
}).pipe(
Effect.provide(
LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]),
),
)
}
if (input.workspaceID === null) {
+4 -2
View File
@@ -38,7 +38,8 @@ import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { InstanceLayer } from "@/project/instance-layer"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
@@ -51,6 +52,7 @@ import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
@@ -101,7 +103,7 @@ export const AppLayer = Layer.mergeAll(
SessionShare.defaultLayer,
).pipe(
Layer.provideMerge(Ripgrep.defaultLayer),
Layer.provideMerge(InstanceLayer.layer),
Layer.provideMerge(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])),
Layer.provideMerge(Observability.layer),
)
@@ -43,6 +43,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
+2 -1
View File
@@ -72,7 +72,8 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
if (result.structuredContent === undefined || result.structuredContent === null) return result
if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null)
return result
return {
...result,
content: [{ type: "text" as const, text: JSON.stringify(result.structuredContent) }],
+19
View File
@@ -159,6 +159,12 @@ export interface Interface {
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, Tool>>
/**
* Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`).
* Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code
* mode uses to render tool signatures (including return types) to the model.
*/
readonly defs: () => Effect.Effect<Record<string, MCPToolDef>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
@@ -680,6 +686,18 @@ export const layer = Layer.effect(
return result
})
const defs = Effect.fn("MCP.defs")(function* () {
const result: Record<string, MCPToolDef> = {}
const s = yield* InstanceState.get(state)
for (const [clientName, listed] of Object.entries(s.defs)) {
if (s.status[clientName]?.status !== "connected") continue
for (const mcpTool of listed) {
result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool
}
}
return result
})
function collectFromConnected<T extends { name: string }>(
s: State,
listFn: (c: Client, timeout?: number) => Promise<T[]>,
@@ -982,6 +1000,7 @@ export const layer = Layer.effect(
clients,
instructions,
tools,
defs,
prompts,
resources,
resourceTemplates,
+2 -2
View File
@@ -1,4 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "@/lsp/lsp"
@@ -62,7 +62,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
]),
)
export const node = LayerNode.make({
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, ShareNext.node, Snapshot.node, Vcs.node],
@@ -1,11 +0,0 @@
import { Effect, Layer } from "effect"
import { InstanceStore } from "./instance-store"
export const layer = Layer.unwrap(
Effect.promise(async () => {
const { InstanceBootstrap } = await import("./bootstrap")
return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
}),
)
export * as InstanceLayer from "./instance-layer"
@@ -1,4 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node"
import { GlobalBus } from "@/bus/global"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -8,7 +9,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
import { type InstanceContext } from "./instance-context"
import { InstanceBootstrap } from "./bootstrap-service"
import { InstanceBootstrap as InstanceBootstrapGraph } from "./bootstrap"
import * as Project from "./project"
export interface LoadInput {
@@ -204,10 +204,12 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
export const node = LayerNode.make({
export const bootstrapNode = LayerNode.unbound(InstanceBootstrap.Service, Node.tags.values.global)
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Project.node, InstanceBootstrapGraph.node],
deps: [Project.node, bootstrapNode],
})
export * as InstanceStore from "./instance-store"
@@ -22,6 +22,7 @@ import { McpAuth } from "@/mcp/auth"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
@@ -301,7 +302,7 @@ export function createRoutes(
),
Layer.provide(locationServiceMapLayer),
Layer.provide(LayerNode.compile(app)),
Layer.provide(LayerNode.compile(app, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])),
)
}
+832
View File
@@ -0,0 +1,832 @@
import { Tool } from "@/tool/tool"
import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import { Effect, Schema } from "effect"
import { Rune } from "./rune/rune"
import type { ExecutionLimits, LogEntry } from "./rune/rune"
import type { HostTools } from "./rune/tool-runtime"
export const CODE_MODE_TOOL = "execute"
/**
* Execution limits for the Rune interpreter. `maxDataBytes` is raised well above
* the Rune default (256KB) because code mode forwards base64 media attachments,
* and the timeout matches the default MCP request timeout.
*/
const CODE_LIMITS: ExecutionLimits = {
maxDataBytes: 10_000_000,
timeoutMs: 30_000,
}
export const Parameters = Schema.Struct({
code: Schema.String.annotate({
description: "JavaScript to run. Discover tools with `tools.$rune.search`/`tools.$rune.describe`, call them, and `return` the final value.",
}),
})
/** One child tool call, surfaced live so the UI can render a per-call line that
* updates as the program runs. `tool` is the dotted path (e.g. `github.create_issue`). */
export type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
type Metadata = {
toolCalls: CallEntry[]
error?: boolean
}
/**
* A real attachment: identical to a session `FilePart` (minus the ids) and carrying
* the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into
* `Tool.ExecuteResult.attachments`. This never crosses into the sandbox the program
* only ever sees the opaque {@link AttachmentHandle}.
*/
export type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]
/**
* The opaque, model-facing view of an attachment: metadata only, no bytes. A program
* can inspect `mime`/`filename`/`bytes`, propagate the handle (return it to show the
* user) or drop it, but can NOT read or leak the contents so a stray `return`/log
* can never dump a base64 blob back into the conversation.
*/
export type AttachmentHandle = { type: "file"; id: string; mime: string; filename?: string; bytes?: number }
/** The envelope every tool call resolves to, and the shape a program should `return`. */
export type Envelope = { result: unknown; attachments?: AttachmentHandle[] }
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
const SEARCH = "search"
const DESCRIBE = "describe"
// The runtime's own capabilities live under `tools.$rune.*`, separated from the
// MCP server namespaces. `$` can never appear in a sanitized server name, so this
// namespace is collision-proof.
const RUNE_NS = "$rune"
type CatalogEntry = {
path: string
key: string
server: string
local: string
description: string
tool: AITool
outputSchema?: JSONSchema7
}
const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim()
const brief = (text: string | undefined, max = 120) => {
const line = firstLine(text)
return line.length > max ? line.slice(0, max - 1) + "…" : line
}
function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return
if (typeof input === "object" && !Array.isArray(input)) {
const value = input as Record<string, unknown>
if (Object.keys(value).length > 0) return value
return
}
return { input }
}
/** Re-join accessed segments into the flat catalog key (`server_tool`). The
* server/tool split is cosmetic, so `tools.a.b`, `tools["a.b"]`, `a/b`, and `a_b`
* all resolve to the same key the model never has to guess the separator. */
const toKey = (segments: readonly string[]) => segments.join("_").replace(/[./]/g, "_")
/**
* Group the flat `server_tool` catalog into per-server namespaces. `servers` are
* the sanitized MCP client names; the longest matching prefix wins so a server
* named `a_b` beats `a` for the key `a_b_tool`. `mcpDefs` carries the raw MCP
* definitions (keyed identically) so each entry retains its `outputSchema`.
*/
export function groupByServer(
mcpTools: Record<string, AITool>,
servers: readonly string[],
mcpDefs: Record<string, MCPToolDef> = {},
): Map<string, CatalogEntry[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, CatalogEntry[]>()
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
const server = byLongest.find((name) => key.startsWith(name + "_")) ?? key.slice(0, key.indexOf("_"))
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
const output = mcpDefs[key]?.outputSchema as JSONSchema7 | undefined
const entry: CatalogEntry = {
path: `${server}.${local}`,
key,
server,
local,
description: mcpTools[key]!.description ?? "",
tool: mcpTools[key]!,
outputSchema: output,
}
groups.set(server, [...(groups.get(server) ?? []), entry])
}
return groups
}
const access = (segment: string) => (IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
/** An object property name, bare when it is a valid identifier, else quoted. */
const propKey = (name: string) => (IDENTIFIER.test(name) ? name : JSON.stringify(name))
/** Join type strings into a union, de-duplicating members and preserving order. An
* empty set of members (e.g. an empty `enum`/`anyOf`) is `never`, never the empty string. */
const asUnion = (parts: string[]) => {
const unique = [...new Set(parts)]
return unique.length > 0 ? unique.join(" | ") : "never"
}
/** Resolve a local JSON-Schema `$ref` (`#/$defs/Foo`) against the document root.
* Returns undefined for external or unresolvable refs. */
function resolveRef(ref: string, root: JSONSchema7 | undefined): JSONSchema7 | undefined {
if (!root || !ref.startsWith("#/")) return undefined
let node: unknown = root
for (const raw of ref.slice(2).split("/")) {
const segment = raw.replace(/~1/g, "/").replace(/~0/g, "~")
if (!node || typeof node !== "object") return undefined
node = (node as Record<string, unknown>)[segment]
}
return node && typeof node === "object" ? (node as JSONSchema7) : undefined
}
const MAX_DEPTH = 8
/** Options for {@link renderType}. `root` anchors `$ref` resolution (defaults to the
* top-level schema); `pretty` switches from a single-line type to an indented,
* JSDoc-annotated block used by `describe`. */
export type RenderOptions = { root?: JSONSchema7; pretty?: boolean }
/**
* Render a JSON Schema as a TypeScript type string for model-facing signatures.
* Total (never throws falls back to `any`/`object`) and cycle-safe: local `$ref`s
* are inlined, self-referential ones collapse to the ref name. Compact by default
* (single line, no docs); `pretty` produces an indented block with `/** … *\/` docs
* on described fields. Handles enums, `const`, `anyOf`/`oneOf` unions, `allOf`
* intersections (the common Pydantic `allOf: [{ $ref }]` shape), nullable `type`
* arrays, tuples, and `additionalProperties`.
*/
export function renderType(
def: JSONSchema7 | boolean | undefined,
options: RenderOptions = {},
depth = 0,
seen: ReadonlySet<JSONSchema7> = new Set(),
): string {
if (!def || typeof def === "boolean") return "any"
// Absolute recursion ceiling. Object/array recursion increments `depth`, and so do
// the union/nullable branches below, so this bounds every recursion path — including
// pure-union structural cycles that the `$ref` `seen` guard cannot see. Keeps the
// "never throws" contract even for pathological (non-JSON-transport) input.
if (depth > MAX_DEPTH) return "any"
const root = options.root ?? def
const opts: RenderOptions = { ...options, root }
if (typeof def.$ref === "string") {
const target = resolveRef(def.$ref, root)
const name = def.$ref.split("/").pop() || "any"
if (!target) return "any"
if (seen.has(target)) return name // recursive type: reference by name rather than loop
return renderType(target, opts, depth, new Set([...seen, target]))
}
if (Array.isArray(def.enum)) return asUnion(def.enum.map((value) => JSON.stringify(value)))
if (def.const !== undefined) return JSON.stringify(def.const)
// allOf = intersection. The dominant Pydantic/FastMCP shape is `allOf: [{ $ref }]`
// with a sibling description/default, so a single member renders as just that member;
// any base `properties` on `def` itself are intersected in.
if (Array.isArray(def.allOf) && def.allOf.length > 0) {
const base = def.properties || def.additionalProperties !== undefined ? renderObject(def, opts, depth, seen) : undefined
const members = def.allOf.map((member) => renderType(member as JSONSchema7, opts, depth + 1, seen))
const parts = [...(base ? [base] : []), ...members].filter((part) => part !== "any")
return parts.length === 0 ? "any" : parts.length === 1 ? parts[0]! : parts.join(" & ")
}
// Nullable / multi-type: `["string","null"]` -> `string | null` (don't drop members).
if (Array.isArray(def.type)) {
return asUnion(def.type.map((type) => renderType({ ...def, type }, opts, depth + 1, seen)))
}
switch (def.type) {
case "integer":
return "number"
case "string":
case "number":
case "boolean":
case "null":
return def.type
case "array": {
const items = Array.isArray(def.items) ? def.items[0] : def.items
const inner = renderType(items as JSONSchema7 | undefined, opts, depth + 1, seen)
return /[ |&]/.test(inner) ? `(${inner})[]` : `${inner}[]`
}
}
if (def.type === "object" || def.properties || def.additionalProperties !== undefined) {
return renderObject(def, opts, depth, seen)
}
// anyOf / oneOf union — checked after object handling so a base object paired with a
// `require one of` anyOf still renders its properties instead of collapsing to a union.
const union = def.anyOf ?? def.oneOf
if (Array.isArray(union)) return asUnion(union.map((alt) => renderType(alt as JSONSchema7, opts, depth + 1, seen)))
return "any"
}
/** Schema constraints that a TypeScript type can't express natively but a model
* benefits from, surfaced as JSDoc tags (`@default`, `@format`, `@deprecated`, ). */
function docTags(schema: JSONSchema7 | boolean | undefined): string[] {
if (!schema || typeof schema === "boolean") return []
// `deprecated` is a later JSON-Schema draft than the `ai` JSONSchema7 type models.
const s = schema as JSONSchema7 & { deprecated?: boolean }
const tags: string[] = []
if (s.deprecated === true) tags.push("@deprecated")
if (s.default !== undefined) {
try {
tags.push(`@default ${JSON.stringify(s.default)}`)
} catch {
// unserializable default: skip rather than emit a broken tag
}
}
if (typeof s.format === "string") tags.push(`@format ${s.format}`)
if (typeof s.minItems === "number") tags.push(`@minItems ${s.minItems}`)
if (typeof s.maxItems === "number") tags.push(`@maxItems ${s.maxItems}`)
return tags
}
/**
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
* preserving multi-line text (a single line stays `/** … *\/`; multiple lines become a
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
* callers can prepend it directly to the field line.
*/
function jsdoc(description: string | undefined, tags: string[], pad: string): string {
const lines = [...(description ? description.split("\n") : []), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
)
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line ? ` ${line}` : ""}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
function renderObject(
def: JSONSchema7,
opts: RenderOptions,
depth: number,
seen: ReadonlySet<JSONSchema7>,
): string {
const props = (def.properties ?? {}) as Record<string, JSONSchema7>
const names = Object.keys(props)
const additional = def.additionalProperties
const indexType =
additional === true ? "any" : additional && typeof additional === "object" ? renderType(additional, opts, depth + 1, seen) : undefined
if (names.length === 0) return indexType ? `{ [key: string]: ${indexType} }` : "object"
if (depth >= MAX_DEPTH) return "object"
const required = new Set(Array.isArray(def.required) ? def.required : [])
const field = (name: string) => `${propKey(name)}${required.has(name) ? "" : "?"}: ${renderType(props[name], opts, depth + 1, seen)}`
if (!opts.pretty) {
const fields = names.map(field)
if (indexType) fields.push(`[key: string]: ${indexType}`)
return `{ ${fields.join("; ")} }`
}
const pad = " ".repeat(depth + 1)
const lines = names.map((name) => `${jsdoc(props[name]?.description, docTags(props[name]), pad)}${pad}${field(name)}`)
if (indexType) lines.push(`${pad}[key: string]: ${indexType}`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
function inputType(tool: AITool): string {
try {
const schema = asSchema(tool.inputSchema).jsonSchema as JSONSchema7 | undefined
if (!schema?.properties || typeof schema.properties !== "object") return "input"
return renderType(schema)
} catch {
return "input"
}
}
/** The `T` in `Result<T>`: the structured `outputSchema` (when the MCP server declares
* one), else `unknown` an untyped result has no guaranteed shape and must be inspected,
* not assumed. `Result<T>` itself is defined once in the tool description prose. */
const resultType = (outputSchema: JSONSchema7 | undefined) => (outputSchema ? renderType(outputSchema) : "unknown")
/** The full, awaited call type shown by `tools.$rune.describe`. */
const returnType = (outputSchema: JSONSchema7 | undefined) => `Promise<Result<${resultType(outputSchema)}>>`
const signatureFor = (entry: CatalogEntry) =>
`tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}`
/** The directly-callable signature for the inline preview. Unlike the full `describe`
* form it drops the uniform `Promise<…>` wrapper (calls are always awaited) but DOES
* show the awaited `Result<T>` so the model sees each tool's return shape without a
* discovery round-trip. */
const previewSignature = (entry: CatalogEntry) =>
`tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): Result<${resultType(entry.outputSchema)}>`
/**
* Character budget for the inline signature preview in the tool description. All
* namespaces are always listed; per-tool call signatures are previewed (cheapest
* first, server by server) until this many characters are used, after which the
* remaining namespaces show counts only. This front-loads a directly-callable slice
* of the catalog cutting discovery round-trips without dumping every signature.
*/
const PREVIEW_BUDGET = 2000
/**
* The execute tool description: the calling convention, the discovery API, and the
* list of namespaces. A budgeted preview of per-tool call signatures is inlined; the
* full typed signature + schemas are fetched on demand with `tools.$rune.describe`,
* and any tool not previewed must be found via `tools.$rune.search` first.
*/
export function describe(groups: Map<string, CatalogEntry[]>): string {
const lines = [
"Execute JavaScript with access to connected MCP tools, grouped into namespaces (one per MCP server).",
"",
"The runtime provides two discovery capabilities under `tools.$rune` (its own namespace, separate",
"from your MCP servers):",
"- `await tools.$rune.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`",
"- `await tools.$rune.describe(path)` -> `{ path, description, signature, input, output? }` (types as TypeScript)",
"",
"Call a tool by its path: `await tools.<server>.<tool>(input)`. Every call — and your final `return` —",
"uses the same envelope: `type Result<T> = { result: T; attachments?: Attachment[] }`. The signatures",
"below (and `tools.$rune.describe`) show each tool's `T` as its return type.",
"",
"`result` (the `T`) is the tool's own payload. It is typed `unknown` unless the server declares an output",
"schema — an `unknown` result has NO guaranteed shape, so inspect it (e.g. `return` it to see it, or read",
"it defensively) before assuming any fields.",
"",
"`attachments` are files a tool produced (an image, a document, …), given to you as references you hold",
"but don't read inline: `type Attachment = { type: 'file'; mime: string; filename?: string; bytes?: number }`.",
"To actually SEE a file — e.g. look at a screenshot before deciding your next step — include it in what you",
"`return` (e.g. `return { result: summary, attachments: shot.attachments }`): returned attachments come back",
"into the conversation as real viewable images/files, so both YOU (on your next turn) and the user can see",
"them. Omit an attachment to discard it. You route whole attachment handles; you don't read their raw bytes.",
"",
"Only what you `return` re-enters the conversation — `result` becomes text; everything else in the sandbox",
"stays there. Compose multiple calls in one program and `return` the final value. Use `tools.$rune.search('', { namespace })` to list a namespace.",
]
if (groups.size === 0) {
lines.push("", "No MCP servers are currently connected.")
return lines.join("\n")
}
// Select which signatures fit the budget (cheapest first within each server,
// servers alphabetical) before emitting, so the list can state exactly how
// comprehensive it is — overall and per namespace.
const ordered = [...groups].sort(([a], [b]) => a.localeCompare(b))
const shown = new Map<string, string[]>()
let used = 0
let budgetLeft = true
let totalTools = 0
let totalShown = 0
for (const [server, entries] of ordered) {
totalTools += entries.length
const picked: string[] = []
if (budgetLeft) {
for (const entry of entries) {
const line = ` - ${previewSignature(entry)}`
if (used + line.length > PREVIEW_BUDGET) {
budgetLeft = false
break
}
picked.push(line)
used += line.length
}
}
shown.set(server, picked)
totalShown += picked.length
}
const complete = totalShown === totalTools
lines.push(
"",
complete
? "This is the COMPLETE list of available tools — every connected tool is shown below with its call signature. Use `tools.$rune.describe(path)` for a tool's full types."
: `This is a PARTIAL list — ${totalShown} of ${totalTools} tools are shown below. Any tool not listed must be found with \`tools.$rune.search\` first; use \`tools.$rune.describe(path)\` for full types.`,
)
for (const [server, entries] of ordered) {
const picked = shown.get(server)!
const total = entries.length
const count = `${total} tool${total === 1 ? "" : "s"}`
// Annotate only when a namespace is not fully shown, so a comprehensive
// namespace reads cleanly and a truncated one is unambiguous.
const label =
picked.length === total ? count : picked.length === 0 ? `${count}, none shown` : `${count}, ${picked.length} shown`
lines.push(`- ${server} (${label})`)
for (const line of picked) lines.push(line)
}
return lines.join("\n")
}
const lastSegment = (uri: string) => {
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
return segment.length > 0 ? segment : undefined
}
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
/** Decoded byte length of a `data:` URL's base64 payload, or undefined for a
* non-data URL (e.g. an external `resource_link`) whose size we don't know. */
function dataUrlBytes(url: string): number | undefined {
if (!url.startsWith("data:")) return undefined
const comma = url.indexOf(",")
if (comma === -1) return undefined
const base64 = url.slice(comma + 1)
if (base64.length === 0) return 0
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding)
}
/** Functions for converting between real attachments and the opaque handles the
* sandbox sees. See {@link attachmentTable}. */
export type AttachmentTable = {
/** Register a real attachment, returning the opaque handle to hand to the program. */
seal: (attachment: Attachment) => AttachmentHandle
/** Resolve a handle the program returned back to its real attachment, or undefined
* if it isn't one this table issued (a fabricated or stale handle is dropped). */
resolve: (handle: unknown) => Attachment | undefined
}
/**
* A per-execution table that keeps real attachment bytes host-side and only ever
* exposes opaque handles to the sandbox. The bytes never enter the program's context,
* so a program cannot read or accidentally re-emit them; on `return`, a propagated
* handle is looked up here to recover the real attachment for the user.
*/
export function attachmentTable(): AttachmentTable {
const real = new Map<string, Attachment>()
let seq = 0
return {
seal(attachment) {
const id = `att_${++seq}`
real.set(id, attachment)
const bytes = dataUrlBytes(attachment.url)
return {
type: "file",
id,
mime: attachment.mime,
...(attachment.filename ? { filename: attachment.filename } : {}),
...(bytes !== undefined ? { bytes } : {}),
}
},
resolve(handle) {
if (!handle || typeof handle !== "object") return undefined
const id = (handle as Record<string, unknown>).id
return typeof id === "string" ? real.get(id) : undefined
},
}
}
/**
* Reduce an MCP tool result to the `{ result, attachments? }` envelope. `result`
* is the structured content (or joined text); media blocks (image/audio/resource)
* become opaque attachment handles via `seal` (the bytes stay host-side). Lenient
* never throws on unexpected shapes.
*/
export function toEnvelope(result: unknown, seal: AttachmentTable["seal"]): Envelope {
if (result === null || typeof result !== "object") return { result }
const record = result as { structuredContent?: unknown; content?: unknown }
const attachments: AttachmentHandle[] = []
const push = (attachment: Attachment) => attachments.push(seal(attachment))
const text: string[] = []
const content = Array.isArray(record.content) ? record.content : []
for (const item of content) {
if (!item || typeof item !== "object") continue
const block = item as Record<string, unknown>
switch (block.type) {
case "text":
if (typeof block.text === "string") text.push(block.text)
break
case "image":
case "audio":
if (typeof block.data === "string" && typeof block.mimeType === "string") {
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
}
break
case "resource": {
const res = block.resource as Record<string, unknown> | undefined
if (res && typeof res === "object") {
const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream"
const uri = typeof res.uri === "string" ? res.uri : undefined
if (typeof res.blob === "string") {
push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined })
} else if (typeof res.text === "string") {
text.push(res.text)
}
}
break
}
case "resource_link":
if (typeof block.uri === "string") {
push({
type: "file",
mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream",
url: block.uri,
filename: typeof block.name === "string" ? block.name : lastSegment(block.uri),
})
}
break
}
}
const value =
record.structuredContent !== undefined && record.structuredContent !== null
? record.structuredContent
: text.length > 0
? text.join("\n")
: content.length > 0
? undefined // media-only result
: result
return attachments.length > 0 ? { result: value, attachments } : { result: value }
}
/**
* Append captured `console.*` output to the model-facing text as a trailing `Logs:` section,
* so a program's diagnostics ride back alongside its result (and errors). Each line is
* `[level] message`; returns the text unchanged when nothing was logged. This is the sandbox's
* only stdout-like channel it goes to the model, not the user.
*/
export function withLogs(output: string, logs: ReadonlyArray<LogEntry>): string {
if (logs.length === 0) return output
const section = "Logs:\n" + logs.map((entry) => `[${entry.level}] ${entry.message}`).join("\n")
return output.length > 0 ? `${output}\n\n${section}` : section
}
/** Coerce the program's return value to model-facing text without ever failing on shape. */
export function formatValue(value: unknown): string {
if (typeof value === "string") return value
if (value === undefined) return "undefined"
try {
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
}
}
/**
* Lower the program's return value into model-facing output + attachments. The value
* is treated as a `{ result, attachments? }` envelope when it has a `result` key;
* otherwise the whole value is the result. Attachments are model-curated: each returned
* handle is resolved back to its real bytes via `resolve`; anything that isn't a handle
* this run issued is dropped.
*/
export function fromReturn(
value: unknown,
resolve: AttachmentTable["resolve"],
): { output: string; attachments?: Attachment[] } {
if (value !== null && typeof value === "object" && "result" in value) {
const env = value as { result: unknown; attachments?: unknown }
const attachments = Array.isArray(env.attachments)
? env.attachments.map(resolve).filter((a): a is Attachment => a !== undefined)
: []
return attachments.length > 0
? { output: formatValue(env.result), attachments }
: { output: formatValue(env.result) }
}
return { output: formatValue(value) }
}
/** A search-indexed catalog entry: the fields ranking matches against, with
* `searchText` (path + description + parameter names/descriptions) precomputed. */
export type SearchEntry = { path: string; server: string; description: string; searchText: string }
/** The lowercased searchable text for a tool: its path, description, and the name
* (and description, when present) of each input parameter. */
function searchTextFor(entry: CatalogEntry): string {
const parts = [entry.path, entry.description]
try {
const schema = asSchema(entry.tool.inputSchema).jsonSchema as JSONSchema7 | undefined
const props = schema?.properties
if (props && typeof props === "object") {
for (const [name, value] of Object.entries(props)) {
parts.push(name)
const desc = (value as JSONSchema7 | undefined)?.description
if (typeof desc === "string") parts.push(desc)
}
}
} catch {
// fall back to path + description only
}
return parts.join("\n").toLowerCase()
}
/**
* Split a query into lowercased search terms. camelCase boundaries are split
* (`resolveLibrary` -> `resolve library`) and `_ - . /` are treated as separators,
* so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all tokenize
* alike. Empties and the `*` wildcard are dropped.
*/
const tokenize = (query: string) =>
query
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((term) => term.length > 0 && term !== "*")
/**
* Rank catalog entries against a query using tokenized, field-weighted scoring
* (adapted from the deferred-tool-search bridge). Each term contributes per field:
* exact tool name (20) > path substring (8) > description (4) > any searchable text (2),
* summed across terms. Because paths are `server.tool`, the exact tier matches a
* whole path segment (e.g. the term `search` matches `github.search`). An empty
* query lists everything (alphabetical). Results are ranked by score, tie-broken by path.
*/
export function rankTools(
entries: ReadonlyArray<SearchEntry>,
query: string,
namespace?: string,
limit = 25,
): { items: { path: string; description: string }[]; total: number } {
const terms = tokenize(query)
const scoped = namespace ? entries.filter((entry) => entry.server === namespace) : entries
const ranked = scoped
.map((entry) => {
const path = entry.path.toLowerCase()
const description = entry.description.toLowerCase()
const score = terms.reduce(
(total, term) =>
total +
(path === term || path.endsWith(`.${term}`) ? 20 : 0) +
(path.includes(term) ? 8 : 0) +
(description.includes(term) ? 4 : 0) +
(entry.searchText.includes(term) ? 2 : 0),
0,
)
return { entry, score }
})
.filter((item) => terms.length === 0 || item.score > 0)
.sort((a, b) => b.score - a.score || a.entry.path.localeCompare(b.entry.path))
return {
items: ranked.slice(0, limit).map(({ entry }) => ({ path: entry.path, description: brief(entry.description) })),
total: ranked.length,
}
}
export function define(
mcpTools: Record<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
servers: readonly string[],
) {
const groups = groupByServer(mcpTools, servers, mcpDefs)
const catalog: CatalogEntry[] = [...groups.values()].flat()
const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const))
const index: SearchEntry[] = catalog.map((entry) => ({
path: entry.path,
server: entry.server,
description: entry.description,
searchText: searchTextFor(entry),
}))
const search = (query: unknown, options: unknown) => {
const q = typeof query === "string" ? query : ""
const opts = (options ?? {}) as { namespace?: unknown; limit?: unknown }
const namespace = typeof opts.namespace === "string" ? opts.namespace : undefined
const limit = typeof opts.limit === "number" && opts.limit > 0 ? Math.floor(opts.limit) : 25
return rankTools(index, q, namespace, limit)
}
const describeTool = (path: unknown) => {
if (typeof path !== "string") return { error: { code: "invalid_path", message: "describe expects a tool path string." } }
const entry = byKey.get(toKey([path]))
if (!entry) {
// Fuzzy "did you mean": rank the leaf name within its namespace, then fall
// back to a global search. Split only on namespace separators (`. _ /`) so a
// hyphenated tool name (e.g. `resolve-library-id`) stays one searchable leaf.
const segments = path.split(/[._/]+/).filter((s) => s.length > 0)
const leaf = segments.at(-1) ?? path
const namespace = segments.length > 1 ? segments[0] : undefined
const scoped = namespace ? rankTools(index, leaf, namespace, 5).items : []
const suggestions = (scoped.length > 0 ? scoped : rankTools(index, leaf, undefined, 5).items).map((i) => i.path)
return { error: { code: "tool_not_found", message: `No tool at '${path}'.`, suggestions } }
}
// Everything the model sees is TypeScript: `signature` is the compact one-line
// call form; `input`/`output` are the detailed types (multi-line, with JSDoc for
// any described fields and literal unions for enums) that raw JSON Schema used to
// carry. `output` is present only when the server declares an outputSchema.
let input = "unknown"
try {
const schema = asSchema(entry.tool.inputSchema).jsonSchema as JSONSchema7 | undefined
input = renderType(schema, { pretty: true })
} catch {
input = "unknown"
}
return {
path: entry.path,
description: entry.description,
signature: signatureFor(entry),
input,
...(entry.outputSchema ? { output: renderType(entry.outputSchema, { pretty: true }) } : {}),
}
}
return Tool.define(
CODE_MODE_TOOL,
Effect.succeed<Tool.DefWithoutID<typeof Parameters, Metadata>>({
description: describe(groups),
parameters: Parameters,
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
const calls: CallEntry[] = []
// Real attachment bytes stay in this table for the life of the call; the sandbox
// only ever handles opaque references to them (see attachmentTable).
const files = attachmentTable()
// Stream the current call list to the UI. Sent on every status change so the
// tool part shows each child call appearing and resolving while the program runs.
const publish = (error?: boolean) =>
ctx.metadata({ title: "execute", metadata: { toolCalls: calls.map((c) => ({ ...c })), ...(error ? { error } : {}) } })
const mark = (index: number, status: CallEntry["status"]) =>
Effect.suspend(() => {
calls[index] = { ...calls[index]!, status }
return publish()
})
const tracked = <A, E, R>(tool: string, input: unknown, effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const index = calls.length
const childInput = displayInput(input)
calls.push({ tool, status: "running", ...(childInput ? { input: childInput } : {}) })
yield* publish()
return yield* effect.pipe(
Effect.tap(() => mark(index, "completed")),
Effect.tapError(() => mark(index, "error")),
)
})
// One host function per MCP tool: gate on permission, dispatch to the native
// MCP tool, and coerce the result into the { result, attachments? } envelope.
// A failure (e.g. an MCP isError) fails the Effect, which the interpreter
// surfaces as a catchable in-program error.
const callTool = (entry: CatalogEntry) => (input: unknown) =>
Effect.gen(function* () {
yield* ctx.ask({ permission: entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* tracked(entry.path, input, Effect.tryPromise({
try: () =>
Promise.resolve(
entry.tool.execute!(input ?? {}, {
toolCallId: ctx.callID ?? entry.key,
abortSignal: ctx.abort,
messages: [],
}),
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.map((raw) => toEnvelope(raw, files.seal)),
))
})
// The Rune host-tool tree: per-server namespaces (`tools.<server>.<tool>`)
// plus the runtime's own discovery capabilities under `tools.$rune.*`. The
// interpreter resolves and invokes these; approving `execute` does not
// approve any child call.
const tools: HostTools = {
[RUNE_NS]: {
[SEARCH]: (query: unknown, options: unknown) =>
tracked(
"$rune.search",
{ query, ...(typeof options === "object" && options !== null && !Array.isArray(options) ? options : {}) },
Effect.succeed(search(query, options)),
),
[DESCRIBE]: (path: unknown) => tracked("$rune.describe", { path }, Effect.succeed(describeTool(path))),
},
}
for (const entry of catalog) {
if (!entry.tool.execute) continue
let namespace = tools[entry.server] as HostTools | undefined
if (!namespace) {
namespace = {}
tools[entry.server] = namespace
}
namespace[entry.local] = callTool(entry)
}
const result = yield* Rune.execute({
code: params.code,
tools: tools as unknown as Record<string, never>,
limits: CODE_LIMITS,
})
if (result.ok) {
const { output, attachments } = fromReturn(result.value, files.resolve)
return {
title: "execute",
metadata: { toolCalls: calls },
output: withLogs(output, result.logs),
...(attachments && attachments.length > 0 ? { attachments } : {}),
} satisfies Tool.ExecuteResult<Metadata>
}
// Point the model at discovery when it references a tool that does not exist.
const hint =
result.error.kind === "UnknownCapability"
? "\nUse tools.$rune.search(query) to discover available tools."
: ""
return {
title: "execute",
metadata: { toolCalls: calls, error: true },
output: withLogs(result.error.message + hint, result.logs),
} satisfies Tool.ExecuteResult<Metadata>
}),
}),
)
}
+2
View File
@@ -1237,6 +1237,8 @@ export const layer = Layer.effect(
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(Agent.Service, agents),
Effect.provideService(RuntimeFlags.Service, flags),
)
if (lastUser.format?.type === "json_schema") {
@@ -0,0 +1,10 @@
import { Schema } from "effect"
/** Safe operational refusal from a standard capability pack, reported as `CapabilityFailure`. */
export class CapabilityError extends Schema.TaggedErrorClass<CapabilityError>()("CapabilityError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export const capabilityError = (message: string, cause?: unknown): CapabilityError =>
new CapabilityError({ message, ...(cause === undefined ? {} : { cause }) })

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