diff --git a/.changeset/instant-agent-manager-terminal.md b/.changeset/instant-agent-manager-terminal.md new file mode 100644 index 00000000000..4893a22b6d4 --- /dev/null +++ b/.changeset/instant-agent-manager-terminal.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Accept terminal input immediately while the Agent Manager shell starts. diff --git a/.changeset/jetbrains-cli-mode-visibility.md b/.changeset/jetbrains-cli-mode-visibility.md new file mode 100644 index 00000000000..bc9cf774d13 --- /dev/null +++ b/.changeset/jetbrains-cli-mode-visibility.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Log whether the JetBrains plugin downloads Core or uses the bundled/cached version, and mark the Core version shown in the popup as "Bundled" when it wasn't downloaded. diff --git a/.changeset/opencode-v1-17-9-to-v1-17-13.md b/.changeset/opencode-v1-17-9-to-v1-17-13.md new file mode 100644 index 00000000000..529fce727e5 --- /dev/null +++ b/.changeset/opencode-v1-17-9-to-v1-17-13.md @@ -0,0 +1,21 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Changes from opencode v1.17.9 to v1.17.13 upstream: + +- Core Improvements: MCP servers can append their instructions to the model context, and MCP resources are available as tools with template listing. +- Core Improvements: Model variants are generated from models.dev data, including modes exposed as models. +- Core Improvements: Tool definitions pass `strict` through for Codex parity, and Gemini requests support video and audio media. +- Core Bugfixes: Interrupted assistant steps settle instead of leaving sessions stuck busy. +- Core Bugfixes: MCP OAuth reconnects after authorization even when the server is disabled, refreshes credentials on reauthentication, requests refresh token scope, surfaces completion errors, and binds its callback to the IPv4 loopback. +- Core Bugfixes: MCP tool results prefer content over structured output, and denied resource template tools stay hidden. +- Core Bugfixes: Stale GitHub Copilot Responses item IDs are no longer replayed, and OpenAI reasoning variants are forced where required. +- Core Bugfixes: Adaptive thinking is enabled for Claude Sonnet 5, and expired promos were removed from the zen catalog. +- Core Bugfixes: Preserve released prompt history during database replay and keep native event streams connected for all supported Kilo events. +- Core Bugfixes: Remote skill manifests support optional per-skill versions; changing a version refreshes the cached skill atomically, and skill base directories are emitted as filesystem paths. +- CLI Improvements: Ports increment from the default when busy. +- CLI Improvements: Use `--auto` to start the TUI in a run-scoped auto-approve mode, and leave the mode mid-session from the command palette. +- TUI Improvements: Redesigned crash screen, model picker sorted by release date, bindable diff viewer and Move Session commands, main-branch diff source, and inline skill load errors. +- TUI Bugfixes: File autocomplete is scoped to the session, multi-day durations format correctly, and root sessions load in the session switcher. diff --git a/.changeset/privacy-mode-tui.md b/.changeset/privacy-mode-tui.md new file mode 100644 index 00000000000..9131915a7e9 --- /dev/null +++ b/.changeset/privacy-mode-tui.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add a privacy mode that blurs PII in the TUI (personal balance, Kilo Pass usage, etc.) and requires confirmation before `/profile` reveals email, name, balance, and team. Toggle with the new `/privacy` command or by setting `privacy_mode` in `kilo.json`. The `kilo profile` CLI command is unaffected. \ No newline at end of file diff --git a/.changeset/prompt-rail-panel-edge.md b/.changeset/prompt-rail-panel-edge.md new file mode 100644 index 00000000000..e6de692cc30 --- /dev/null +++ b/.changeset/prompt-rail-panel-edge.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Move the prompt navigator rail to the panel edge so it is harder to open by accident, and keep it clear of the pane splitter while resizing. diff --git a/.changeset/quiet-jetbrains-watchers.md b/.changeset/quiet-jetbrains-watchers.md new file mode 100644 index 00000000000..3e07e6c6981 --- /dev/null +++ b/.changeset/quiet-jetbrains-watchers.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix high CPU and runaway memory growth in the JetBrains background `kilo serve` process on macOS by no longer eagerly starting native file watchers, matching the VS Code backend. diff --git a/.changeset/quiet-sqlite-lock-errors.md b/.changeset/quiet-sqlite-lock-errors.md new file mode 100644 index 00000000000..db7cb238f9d --- /dev/null +++ b/.changeset/quiet-sqlite-lock-errors.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show a concise retryable message when concurrent Kilo processes temporarily lock the SQLite database instead of printing the full server error trace. diff --git a/.changeset/quiet-worktrees-list.md b/.changeset/quiet-worktrees-list.md new file mode 100644 index 00000000000..71bd8445ece --- /dev/null +++ b/.changeset/quiet-worktrees-list.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent Agent Manager overview requests from timing out while refreshing Git statistics across many worktrees. diff --git a/.changeset/worktree-slash-commands.md b/.changeset/worktree-slash-commands.md new file mode 100644 index 00000000000..bc008e5df72 --- /dev/null +++ b/.changeset/worktree-slash-commands.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support model, agent, variant, and sandbox slash commands in Agent Manager worktree prompts. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1d4d1916ab9..e3b64495f37 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,4 +2,4 @@ blank_issues_enabled: false contact_links: - name: 💬 Join our Discord url: https://kilo.ai/discord - about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question. + about: For support, troubleshooting, how-to questions, and real-time discussion. diff --git a/.github/workflows/disabled/compliance-close.yml.disabled b/.github/workflows/disabled/compliance-close.yml.disabled index 14e68701e57..a83824e5cf6 100644 --- a/.github/workflows/disabled/compliance-close.yml.disabled +++ b/.github/workflows/disabled/compliance-close.yml.disabled @@ -34,10 +34,48 @@ jobs: const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; + const orgMemberAssociations = new Set(['OWNER', 'MEMBER']); + const agentLogin = 'opencode-agent[bot]'; + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'dev', + }); + const teamMembers = new Set( + Buffer.from(file.content, 'base64') + .toString() + .split('\n') + .map((line) => line.trim().toLowerCase()) + .filter(Boolean) + ); + + function isExempt(item) { + const login = item.user?.login?.toLowerCase(); + return ( + login === agentLogin || + orgMemberAssociations.has(item.author_association) || + (login && teamMembers.has(login)) + ); + } for (const item of items) { const isPR = !!item.pull_request; const kind = isPR ? 'PR' : 'issue'; + const login = item.user?.login; + + if (isExempt(item)) { + core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`); + 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, diff --git a/.github/workflows/disabled/duplicate-issues.yml.disabled b/.github/workflows/disabled/duplicate-issues.yml.disabled index c90320bd45c..f0e68202481 100644 --- a/.github/workflows/disabled/duplicate-issues.yml.disabled +++ b/.github/workflows/disabled/duplicate-issues.yml.disabled @@ -17,12 +17,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://kilo.ai/install | bash - name: Check duplicates and compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -38,6 +57,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 +69,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 +105,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: 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 @@ -129,12 +151,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://kilo.ai/install | bash - name: Recheck compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -148,9 +189,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: diff --git a/.github/workflows/disabled/storybook.yml.disabled b/.github/workflows/disabled/storybook.yml.disabled index 1e652104d69..be2e099d0ed 100644 --- a/.github/workflows/disabled/storybook.yml.disabled +++ b/.github/workflows/disabled/storybook.yml.disabled @@ -9,6 +9,7 @@ on: - "bun.lock" - "packages/storybook/**" - "packages/ui/**" + - "packages/session-ui/**" pull_request: branches: [dev] paths: @@ -17,6 +18,7 @@ on: - "bun.lock" - "packages/storybook/**" - "packages/ui/**" + - "packages/session-ui/**" workflow_dispatch: concurrency: diff --git a/.github/workflows/disabled/triage.yml.disabled b/.github/workflows/disabled/triage.yml.disabled index 0b2c9d89a1c..a1dfc0840b3 100644 --- a/.github/workflows/disabled/triage.yml.disabled +++ b/.github/workflows/disabled/triage.yml.disabled @@ -16,13 +16,32 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup Bun + if: steps.author.outputs.skip != 'true' uses: ./.github/actions/setup-bun - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://kilo.ai/install | bash - name: Triage issue + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e02e5b705e..9df9c697de3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,7 +66,6 @@ jobs: echo 'general=true' >> "$GITHUB_OUTPUT" echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT" # kilocode_change end - unit: # kilocode_change start name: ${{ !matrix.settings.run && 'unit (unchanged)' || matrix.settings.total > 1 && format('unit ({0}, {1}/{2})', matrix.settings.os, matrix.settings.index, matrix.settings.total) || format('unit ({0})', matrix.settings.os) }} @@ -146,6 +145,8 @@ jobs: - name: Run non-CLI unit tests if: matrix.settings.run && matrix.settings.packages run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains' + env: + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - non-CLI tests use the watcher-free unit-test profile # kilocode_change end # kilocode_change start - ensure the Darwin profile cannot suppress its own validation @@ -222,10 +223,15 @@ jobs: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}- turbo-${{ runner.os }}- + - name: Check generated client + if: runner.os == 'Linux' + working-directory: packages/client + run: bun run check:generated - name: Run HttpApi exerciser gates run: bun turbo test:httpapi --filter='@kilocode/cli' env: - KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - same rationale as the CLI unit step: a watcher per scenario instance is too heavy for the exerciser + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - a watcher per scenario instance is too heavy for the exerciser + # kilocode_change end # kilocode_change start @@ -251,7 +257,6 @@ jobs: echo "unit=${{ needs.unit.result }}" test "${{ needs.unit.result }}" = "success" # kilocode_change end - # kilocode_change start required: name: test (linux) diff --git a/.kilo/skills/icon-vscode/SKILL.md b/.kilo/skills/icon-vscode/SKILL.md new file mode 100644 index 00000000000..7eb5b388746 --- /dev/null +++ b/.kilo/skills/icon-vscode/SKILL.md @@ -0,0 +1,63 @@ +--- +name: icon-vscode +description: Create or review icons for Kilo's VS Code extension, webviews, and shared icon registry. +--- + +# VS Code Icons + +Use this skill for icon work in `packages/kilo-vscode/`, `packages/kilo-ui/`, and the shared `packages/ui/` icon registry. Keep official VS Code workbench rules separate from Kilo's webview design system. + +## Choose the icon system first + +| Surface | Use | Source of truth | Theme handling | +|---|---|---|---| +| VS Code commands, menus, and editor actions | Codicon, for example `$(add)`, or a 16x16 single-color SVG only when needed | `packages/kilo-vscode/package.json` records usages; [VS Code command icons](https://code.visualstudio.com/api/references/contribution-points#contributes.commands) defines the contract | VS Code themes Codicons; SVGs use light/dark contribution fields | +| Activity bar and view containers | 24x24 centered single-color icon by the VS Code convention; existing Kilo branding is an intentional asset exception | `packages/kilo-vscode/package.json`, `packages/kilo-vscode/assets/icons/` | Follow the contribution point; do not redesign the existing brand mark | +| Marketplace and extension branding | Packaged brand asset | `packages/kilo-vscode/package.json`, `packages/kilo-vscode/assets/icons/` | Existing Kilo assets define their own palette and variants | +| Webview buttons and UI | `Icon` or `IconButton` from `@kilocode/kilo-ui` | `packages/kilo-ui/src/components/icon.tsx`, then `packages/ui/src/components/icon.tsx` | `currentColor` and the VS Code theme bridge | +| Extension-contributed product icon | Existing WOFF2 font entry, for example `$(kilo-logo)` | `contributes.icons` in `packages/kilo-vscode/package.json` and its usages | VS Code product-icon theming | + +Do not draw a custom SVG when an appropriate Codicon or existing registry icon already exists. Do not use a webview icon directly in `package.json`, or a VS Code contribution icon directly in the webview. + +## Existing repository conventions + +- Search `packages/kilo-ui/src/components/icon.tsx` first for Kilo-only icons, then `packages/ui/src/components/icon.tsx` for shared icons. Preserve the existing key spelling, which is mostly kebab-case, and match a visual sibling before adding a new one. +- Webview registry icons are inline SVG path strings, not standalone files. They use `fill="currentColor"` or `stroke="currentColor"`; do not add theme duplicates or literal palettes to registry entries. Standalone brand artwork may use light/dark variants. +- Match the closest registry sibling's `viewBox`. The shared set is mostly `20 20`, with existing `16 16` entries; Kilo-only entries also intentionally use `24 24`. Icons render at 16px (`small`), 20px (`normal`), or 24px (`medium`/`large`). Never paste a path onto a different canvas without rebalancing it. +- The extension's current brand assets are `kilo-light.svg`, `kilo-dark.svg`, `kilo-light.png`, `kilo-dark.png`, and `logo-outline-black.png`. The WOFF2 file is a packaged contribution font, not an editable icon source. +- Registry icons are decorative by default. Icon buttons need an `aria-label` or visible button text; a tooltip or arbitrary `label` attribute is not sufficient unless the wrapper maps it to an accessible name. + +## Geometry rules + +1. Give each icon one clear semantic meaning. A small `+`, status mark, or active-state fill is an acceptable modifier. +2. Start from at least one existing sibling with the same role and match its `viewBox`, visual weight, caps, joins, and padding. Official command SVGs use a 16x16 canvas with 1px padding; official view-container icons use a centered 24x24 canvas. +3. Keep round-capped endpoints away from the edge so caps are not clipped. For registry icons, use the sibling's bounds rather than imposing a new universal padding rule. +4. Use static SVG geometry such as `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`, and `g`. No raster images, external resources, gradients, filters, embedded fonts, or ` + + + ${input.body}${input.script ? `\n ` : ""} + +` +} + +const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)` + +function bootstrapScript(options: BootstrapOptions) { + return `var PROVIDER=${scriptString(options.provider ?? "")}; +var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href; +(function(){ + var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote"); + function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("OpenCode couldn't finish connecting to "+PROVIDER+"."):"OpenCode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from OpenCode."} + function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("OpenCode is now connected to "+PROVIDER+"."):"OpenCode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window.";setTimeout(function(){try{window.close()}catch(e){}},2500)} + try{ + var hash=new URLSearchParams((window.location.hash||"").slice(1)); + var search=new URLSearchParams(window.location.search||""); + var err=hash.get("error")||search.get("error"); + var errDescription=hash.get("error_description")||search.get("error_description"); + var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""}; + fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){ + if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))}); + if(err){fail(errDescription||err);return} + ok(); + }).catch(function(e){fail(String(e&&e.message?e.message:e))}); + }catch(e){fail(String(e&&e.message?e.message:e))} +})()` +} + +function scriptString(value: string) { + return JSON.stringify(value).replaceAll("<", "\\u003c") +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + +// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is +// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a +// host force a scheme without changing the default. +const LIGHT_VARS = ` + --oc-bg: #f8f8f8; + --oc-card: #fcfcfc; + --oc-text-strong: #171717; + --oc-text-base: #6f6f6f; + --oc-text-weak: #8f8f8f; + --oc-border-weak: #e5e5e5; + --oc-icon-strong: #171717; + --oc-icon-base: #8f8f8f; + --oc-icon-weak: #dbdbdb; + --oc-success: #2dba26; + --oc-error: #ed4831; + --oc-detail-bg: #fff8f6; + --oc-detail-border: #fdc3b7; + --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);` + +const DARK_VARS = ` + --oc-bg: #101010; + --oc-card: #161616; + --oc-text-strong: rgba(255,255,255,.936); + --oc-text-base: rgba(255,255,255,.618); + --oc-text-weak: rgba(255,255,255,.422); + --oc-border-weak: #282828; + --oc-icon-strong: #ededed; + --oc-icon-base: #7e7e7e; + --oc-icon-weak: #343434; + --oc-success: #12c905; + --oc-error: #fc533a; + --oc-detail-bg: #28110c; + --oc-detail-border: #6a1206; + --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);` + +const STYLES = ` + :root { color-scheme: light dark;${LIGHT_VARS} + --oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } } + :root[data-theme="dark"] {${DARK_VARS} } + :root[data-theme="light"] {${LIGHT_VARS} } + + * { box-sizing: border-box; } + html, body { margin: 0; height: 100%; } + body { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: var(--oc-bg); + color: var(--oc-text-base); + font-family: var(--oc-font-sans); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + .card { + width: min(100%, 28rem); + padding: 2.25rem 2rem 1.75rem; + background: var(--oc-card); + border: 1px solid var(--oc-border-weak); + border-radius: 14px; + box-shadow: var(--oc-shadow); + text-align: center; + } + .brand { display: flex; justify-content: center; margin-bottom: 1.75rem; } + .brand svg { height: 19px; width: auto; } + .status { display: flex; justify-content: center; margin-bottom: 1.125rem; } + .icon { display: none; line-height: 0; } + .icon svg { display: block; } + .card[data-status="pending"] .icon-pending, + .card[data-status="success"] .icon-success, + .card[data-status="error"] .icon-error { display: block; } + .icon-success { color: var(--oc-success); } + .icon-error { color: var(--oc-error); } + .icon-pending { color: var(--oc-text-weak); } + .headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); } + .message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); } + .detail { + margin: 1.25rem 0 0; + padding: 0.75rem 0.875rem; + text-align: left; + font-family: var(--oc-font-mono); + font-size: 0.8125rem; + line-height: 1.55; + color: var(--oc-text-strong); + background: var(--oc-detail-bg); + border: 1px solid var(--oc-detail-border); + border-radius: 8px; + white-space: pre-wrap; + word-break: break-word; + max-height: 9.5rem; + overflow: auto; + } + .detail[hidden] { display: none; } + .footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); } + .spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; } + @keyframes oc-spin { to { transform: rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } +` + +// OpenCode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo). +const WORDMARK = ` + + + + + + + + + + + + + + + + + ` + +const ICON_CHECK = `` + +const ICON_CROSS = `` + +const ICON_SPINNER = `` diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index faffb273333..22285974d83 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -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: [] }) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index bbfc6014e8c..95219e15148 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,54 +1,38 @@ export * as PermissionV2 from "./permission" +import { makeLocationNode } from "./effect/app-node" import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" +import { Permission } from "@opencode-ai/schema/permission" import { EventV2 } from "./event" import { Location } from "./location" import { AgentV2 } from "./agent" import { SessionV2 } from "./session" import { SessionStore } from "./session/store" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" import { Wildcard } from "./util/wildcard" -import { PermissionSchema } from "./permission/schema" import { PermissionSaved } from "./permission/saved" -export { Effect, Rule, Ruleset } from "./permission/schema" -type Effect = PermissionSchema.Effect -type Rule = PermissionSchema.Rule -type Ruleset = PermissionSchema.Ruleset -const missingAgentPermissions: Ruleset = [{ action: "*", resource: "*", effect: "deny" }] +export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission" +const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }] -export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( - Schema.brand("PermissionV2.ID"), - withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })), -) +export const ID = Permission.ID export type ID = typeof ID.Type -export const Source = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("tool"), - messageID: Schema.String, - callID: Schema.String, - }), -]).annotate({ identifier: "PermissionV2.Source" }) +export const Source = Permission.Source export type Source = typeof Source.Type const RequestFields = { - sessionID: SessionV2.ID, - action: Schema.String, - resources: Schema.Array(Schema.String), - save: Schema.Array(Schema.String).pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - source: Source.pipe(Schema.optional), + sessionID: Permission.Request.fields.sessionID, + action: Permission.Request.fields.action, + resources: Permission.Request.fields.resources, + save: Permission.Request.fields.save, + metadata: Permission.Request.fields.metadata, + source: Permission.Request.fields.source, } -export const Request = Schema.Struct({ - id: ID, - ...RequestFields, -}).annotate({ identifier: "PermissionV2.Request" }) +export const Request = Permission.Request export type Request = typeof Request.Type -export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) +export const Reply = Permission.Reply export type Reply = typeof Reply.Type export const AssertInput = Schema.Struct({ @@ -67,21 +51,11 @@ export type ReplyInput = typeof ReplyInput.Type export const AskResult = Schema.Struct({ id: ID, - effect: PermissionSchema.Effect, + effect: Permission.Effect, }).annotate({ identifier: "PermissionV2.AskResult" }) export type AskResult = typeof AskResult.Type -export const Event = { - Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }), - Replied: EventV2.define({ - type: "permission.v2.replied", - schema: { - sessionID: SessionV2.ID, - requestID: ID, - reply: Reply, - }, - }), -} +export const Event = Permission.Event export class RejectedError extends Schema.TaggedErrorClass()("PermissionV2.RejectedError", {}) {} @@ -90,7 +64,7 @@ export class CorrectedError extends Schema.TaggedErrorClass()("P }) {} export class DeniedError extends Schema.TaggedErrorClass()("PermissionV2.DeniedError", { - rules: PermissionSchema.Ruleset, + rules: Permission.Ruleset, }) {} export class NotFoundError extends Schema.TaggedErrorClass()("PermissionV2.NotFoundError", { @@ -99,7 +73,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Per export type Error = DeniedError | RejectedError | CorrectedError -export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule { +export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule { return ( rulesets .flat() @@ -111,7 +85,7 @@ export function evaluate(action: string, resource: string, ...rulesets: Ruleset[ ) } -export function merge(...rulesets: Ruleset[]): Ruleset { +export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset { return rulesets.flat() } @@ -132,7 +106,7 @@ interface Pending { readonly deferred: Deferred.Deferred } -export const layer = Layer.effect( +const layer = Layer.effect( Service, EffectRuntime.gen(function* () { const events = yield* EventV2.Service @@ -156,7 +130,7 @@ export const layer = Layer.effect( const savedRules = EffectRuntime.fnUntraced(function* () { return (yield* saved.list({ projectID: location.project.id })).map( - (item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), + (item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), ) }) @@ -170,11 +144,11 @@ export const layer = Layer.effect( return agent?.permissions ?? missingAgentPermissions }) - function denied(input: AssertInput, rules: Ruleset) { + function denied(input: AssertInput, rules: Permission.Ruleset) { return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny") } - function relevant(input: AssertInput, rules: Ruleset) { + function relevant(input: AssertInput, rules: Permission.Ruleset) { return rules.filter((rule) => Wildcard.match(input.action, rule.action)) } @@ -183,7 +157,7 @@ export const layer = Layer.effect( if (denied(input, rules)) return { effect: "deny" as const, rules } const all = [...rules, ...(yield* savedRules())] const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect) - const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow" + const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow" return { effect, rules: all } }) @@ -327,3 +301,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node], +}) diff --git a/packages/core/src/permission/saved.ts b/packages/core/src/permission/saved.ts index 4c57ef2aa02..ffc4559afee 100644 --- a/packages/core/src/permission/saved.ts +++ b/packages/core/src/permission/saved.ts @@ -3,23 +3,15 @@ export * as PermissionSaved from "./saved" import { eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/app-node" import { ProjectV2 } from "../project" -import { withStatics } from "../schema" -import { Identifier } from "../util/identifier" import { PermissionTable } from "./sql" +import { PermissionSaved } from "@opencode-ai/schema/permission-saved" -export const ID = Schema.String.pipe( - Schema.brand("PermissionSaved.ID"), - withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })), -) +export const ID = PermissionSaved.ID export type ID = typeof ID.Type -export const Info = Schema.Struct({ - id: ID, - projectID: ProjectV2.ID, - action: Schema.String, - resource: Schema.String, -}).annotate({ identifier: "PermissionSaved.Info" }) +export const Info = PermissionSaved.Info export type Info = typeof Info.Type export const ListInput = Schema.Struct({ @@ -42,7 +34,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/PermissionSaved") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -84,4 +76,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/permission/schema.ts b/packages/core/src/permission/schema.ts deleted file mode 100644 index 2d806dbd8c5..00000000000 --- a/packages/core/src/permission/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -export * as PermissionSchema from "./schema" - -import { Schema } from "effect" - -export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" }) -export type Effect = typeof Effect.Type - -export const Rule = Schema.Struct({ - action: Schema.String, - resource: Schema.String, - effect: Effect, -}).annotate({ identifier: "PermissionV2.Rule" }) -export type Rule = typeof Rule.Type - -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) -export type Ruleset = typeof Ruleset.Type diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index aaef65d3227..f6c071bca66 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,186 +1,167 @@ export * as PluginV2 from "./plugin" -import { createDraft, finishDraft, type Draft } from "immer" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" -import type { ModelV2 } from "./model" -import type { Catalog } from "./catalog" +import { makeLocationNode } from "./effect/app-node" +import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" +import type { Plugin as PluginRuntime } from "@kilocode/plugin/v2/effect" +import { Plugin } from "@opencode-ai/schema/plugin" +import { AgentV2 } from "./agent" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { CommandV2 } from "./command" import { EventV2 } from "./event" +import { Integration } from "./integration" import { KeyedMutex } from "./effect/keyed-mutex" +import { PluginHost } from "./plugin/host" +import { Reference } from "./reference" +import { SkillV2 } from "./skill" +import { State } from "./state" -export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export const ID = Plugin.ID export type ID = typeof ID.Type - -export const Event = { - Added: EventV2.define({ - type: "plugin.added", - schema: { - id: ID, - }, - }), -} - -type HookSpec = { - "catalog.transform": { - input: Catalog.Editor - output: {} - } - "aisdk.language": { - input: { - model: ModelV2.Info - sdk: any - options: Record - } - output: { - language?: LanguageModelV3 - } - } - "aisdk.sdk": { - input: { - model: ModelV2.Info - package: string - options: Record - } - output: { - sdk?: any - } - } -} - -export type Hooks = { - [Name in keyof HookSpec]: Readonly & { - -readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object - ? Draft - : HookSpec[Name]["output"][Field] - } -} - -export type HookFunctions = { - [key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect -} - -export type HookInput = HookSpec[Name]["input"] -export type HookOutput = HookSpec[Name]["output"] - -export type Effect = Effect.Effect - -export function define(input: { id: ID; effect: Effect.Effect }) { - return input -} +export const Event = Plugin.Event export interface Interface { - readonly add: (input: { - id: ID - effect: Effect.Effect - }) => Effect.Effect + readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect readonly remove: (id: ID) => Effect.Effect - readonly triggerFor: ( - id: ID, - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> - readonly trigger: ( - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> + readonly wait: (id: ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Plugin") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - let hooks: { - id: ID - hooks: HookFunctions - scope: Scope.Closeable - }[] = [] const events = yield* EventV2.Service - const scope = yield* Scope.Scope const locks = KeyedMutex.makeUnsafe() + const scope = yield* Scope.make() + const active = new Map() + const loading = new Set() + const waiters = new Map>>() + const failures = new Map>() + let host: Parameters[0] - const svc = Service.of({ - add: Effect.fn("Plugin.add")(function* (input) { - yield* locks.withLock(input.id)( - Effect.gen(function* () { - const existing = hooks.find((item) => item.id === input.id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) - const childScope = yield* Scope.fork(scope) - const result = yield* input.effect.pipe( - Scope.provide(childScope), - Effect.withSpan("Plugin.load", { - attributes: { - "plugin.id": input.id, - }, + const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) { + if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) + + yield* locks.withLock(id)( + Effect.sync(() => { + loading.add(id) + failures.delete(id) + }).pipe( + Effect.andThen( + State.batch( + Effect.gen(function* () { + const existing = active.get(id) + active.delete(id) + if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + + const child = yield* Scope.fork(scope) + yield* effect(host).pipe( + Scope.provide(child), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + ) + yield* events.publish(Event.Added, { id }) + active.set(id, child) + yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { + discard: true, + }) + waiters.delete(id) }), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)), - ) - hooks = [ - ...hooks.filter((item) => item.id !== input.id), - { - id: input.id, - hooks: result ?? {}, - scope: childScope, - }, - ] - yield* events.publish(Event.Added, { id: input.id }) + ), + ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) return Effect.void + failures.set(id, exit) + return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), { + discard: true, + }).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id)))) }), - ) - }), - trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { - return yield* svc.triggerFor(ID.make("*"), name, input, output) - }), - triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) { - const draftEntries = new Map>() - const event = { - ...input, - ...output, - } as Record - - for (const [field, value] of Object.entries(output)) { - if (value && typeof value === "object") { - draftEntries.set(field, createDraft(value)) - event[field] = draftEntries.get(field) - } - } - - for (const item of hooks) { - if (id !== ID.make("*") && item.id !== id) continue - const match = item.hooks[name] - if (!match) continue - yield* match(event as any).pipe( - Effect.withSpan(`Plugin.hook.${name}`, { - attributes: { - plugin: item.id, - hook: name, - }, - }), - ) - } - - for (const [field, draft] of draftEntries) { - event[field] = finishDraft(draft) - } - - return event as any - }), - remove: Effect.fn("Plugin.remove")(function* (id) { - yield* locks.withLock(id)( - Effect.gen(function* () { - const existing = hooks.find((item) => item.id === id) - hooks = hooks.filter((item) => item.id !== id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) - }), - ) - }), + Effect.ensuring(Effect.sync(() => loading.delete(id))), + ), + ) }) - return svc + + const remove = Effect.fn("Plugin.remove")(function* (id: ID) { + if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`) + + yield* locks.withLock(id)( + State.batch( + Effect.gen(function* () { + const current = active.get(id) + active.delete(id) + failures.delete(id) + if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) + }), + ), + ) + }) + + const wait = Effect.fn("Plugin.wait")(function* (id: ID) { + const waiter = yield* Deferred.make() + const pending = yield* locks.withLock(id)( + Effect.sync(() => { + if (active.has(id)) return false + const failure = failures.get(id) + if (failure) return failure + const current = waiters.get(id) ?? new Set() + current.add(waiter) + waiters.set(id, current) + return true + }), + ) + if (!pending) return + if (typeof pending !== "boolean") return yield* pending + yield* Deferred.await(waiter).pipe( + Effect.ensuring( + locks.withLock(id)( + Effect.sync(() => { + const current = waiters.get(id) + current?.delete(waiter) + if (current?.size === 0) waiters.delete(id) + }), + ), + ), + ) + }) + + yield* Effect.addFinalizer((exit) => + Effect.gen(function* () { + active.clear() + yield* State.batch(Scope.close(scope, exit)) + }), + ) + + const service = Service.of({ + add, + remove, + wait, + }) + host = yield* PluginHost.make(service) + return service }), ) -export const locationLayer = layer +export const locationLayer = layer.pipe( + Layer.provideMerge(AgentV2.locationLayer), + Layer.provideMerge(AISDK.locationLayer), + Layer.provideMerge(Catalog.locationLayer), + Layer.provideMerge(CommandV2.locationLayer), + Layer.provideMerge(Integration.locationLayer), + Layer.provideMerge(Reference.locationLayer), +) -// opencode -// sdcok +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + EventV2.node, + AgentV2.node, + AISDK.node, + Catalog.node, + CommandV2.node, + Integration.node, + Reference.node, + SkillV2.node, + ], +}) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index e8a8d8bc9d6..9a763c7ea9b 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -1,12 +1,12 @@ export * as AgentPlugin from "./agent" import path from "path" +import { define } from "./internal" import { Effect } from "effect" import { AgentV2 } from "../agent" import { Global } from "../global" import { Location } from "../location" import { PermissionV2 } from "../permission" -import { PluginV2 } from "../plugin" const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") const BUILD_SYSTEM = @@ -97,10 +97,9 @@ Rules: - If the conversation ends with an unanswered question to the user, preserve that exact question - If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("agent"), - effect: Effect.gen(function* () { - const agent = yield* AgentV2.Service +export const Plugin = define({ + id: "agent", + effect: Effect.fn(function* (ctx) { const location = yield* Location.Service const worktree = location.directory const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] @@ -122,8 +121,8 @@ export const Plugin = PluginV2.define({ { action: "read", resource: "*.env.example", effect: "allow" }, ] - yield* agent.update((editor) => { - editor.update(AgentV2.defaultID, (item) => { + yield* ctx.agent.transform((draft) => { + draft.update(AgentV2.defaultID, (item) => { item.description = "The default agent. Executes tools based on configured permissions." item.system ??= BUILD_SYSTEM item.mode = "primary" @@ -135,7 +134,7 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("plan"), (item) => { + draft.update(AgentV2.ID.make("plan"), (item) => { item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( @@ -154,14 +153,14 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("general"), (item) => { + draft.update(AgentV2.ID.make("general"), (item) => { item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("explore"), (item) => { + draft.update(AgentV2.ID.make("explore"), (item) => { item.description = 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' item.system = PROMPT_EXPLORE @@ -182,21 +181,21 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("compaction"), (item) => { + draft.update(AgentV2.ID.make("compaction"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("title"), (item) => { + draft.update(AgentV2.ID.make("title"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("summary"), (item) => { + draft.update(AgentV2.ID.make("summary"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts deleted file mode 100644 index 694b0bc5649..00000000000 --- a/packages/core/src/plugin/boot.ts +++ /dev/null @@ -1,134 +0,0 @@ -export * as PluginBoot from "./boot" - -import { Context, Deferred, Effect, Layer } from "effect" -import { Credential } from "../credential" -import { Integration } from "../integration" -import { AgentV2 } from "../agent" -import { Catalog } from "../catalog" -import { CommandV2 } from "../command" -import { Config } from "../config" -import { ConfigAgentPlugin } from "../config/plugin/agent" -import { ConfigCommandPlugin } from "../config/plugin/command" -import { ConfigSkillPlugin } from "../config/plugin/skill" -import { ConfigReferencePlugin } from "../config/plugin/reference" -import { EventV2 } from "../event" -import { FSUtil } from "../fs-util" -import { Global } from "../global" -import { Location } from "../location" -import { ModelsDev } from "../models-dev" -import { Npm } from "../npm" -import { PluginV2 } from "../plugin" -import { AgentPlugin } from "./agent" -import { CommandPlugin } from "./command" -import { ConfigProviderPlugin } from "../config/plugin/provider" -import { ModelsDevPlugin } from "./models-dev" -import { ProviderPlugins } from "./provider" -import { SkillV2 } from "../skill" -import { Reference } from "../reference" - -type Plugin = { - id: PluginV2.ID - effect: PluginV2.Effect< - | Catalog.Service - | CommandV2.Service - | Credential.Service - | Integration.Service - | AgentV2.Service - | Npm.Service - | EventV2.Service - | FSUtil.Service - | Global.Service - | Location.Service - | PluginV2.Service - | Config.Service - | ModelsDev.Service - | SkillV2.Service - | Reference.Service - > -} - -export interface Interface { - readonly wait: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/PluginBoot") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const commands = yield* CommandV2.Service - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const integrations = yield* Integration.Service - const agents = yield* AgentV2.Service - const config = yield* Config.Service - const location = yield* Location.Service - const modelsDev = yield* ModelsDev.Service - const npm = yield* Npm.Service - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const skill = yield* SkillV2.Service - const references = yield* Reference.Service - const done = yield* Deferred.make() - - const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { - yield* plugin.add({ - id: input.id, - effect: input.effect.pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Credential.Service, credentials), - Effect.provideService(Integration.Service, integrations), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, references), - Effect.provideService(PluginV2.Service, plugin), - ), - }) - }) - - const boot = Effect.gen(function* () { - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - // kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill. - for (const item of ProviderPlugins) { - yield* add(item) - } - yield* add(ModelsDevPlugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) - }).pipe(Effect.withSpan("PluginBoot.boot")) - - yield* boot.pipe( - Effect.exit, - Effect.flatMap((exit) => Deferred.done(done, exit)), - Effect.forkScoped, - ) - - return Service.of({ - wait: () => Deferred.await(done), - }) - }), -) - -export const locationLayer = layer.pipe( - Layer.provideMerge(Integration.locationLayer), - Layer.provideMerge(Catalog.locationLayer), - Layer.provideMerge(CommandV2.locationLayer), - Layer.provideMerge(Config.locationLayer), - Layer.provideMerge(AgentV2.locationLayer), - Layer.provideMerge(SkillV2.locationLayer), - Layer.provideMerge(Reference.locationLayer), -) diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 66386a2128e..cbafd68b502 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,25 +1,21 @@ export * as CommandPlugin from "./command" +import { define } from "./internal" import { Effect } from "effect" -import { CommandV2 } from "../command" import { Location } from "../location" -import { PluginV2 } from "../plugin" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("command"), - effect: Effect.gen(function* () { - const command = yield* CommandV2.Service +export const Plugin = define({ + id: "command", + effect: Effect.fn(function* (ctx) { const location = yield* Location.Service - const transform = yield* command.transform() - - yield* transform((editor) => { - editor.update("init", (command) => { + yield* ctx.command.transform((draft) => { + draft.update("init", (command) => { command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) command.description = "guided AGENTS.md setup" }) - editor.update("review", (command) => { + draft.update("review", (command) => { command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.subtask = true diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts new file mode 100644 index 00000000000..13663e33e5e --- /dev/null +++ b/packages/core/src/plugin/host.ts @@ -0,0 +1,219 @@ +export * as PluginHost from "./host" + +import type { PluginContext as Interface } from "@kilocode/plugin/v2/effect" +import { Effect, Schema } from "effect" +import { AgentV2 } from "../agent" +import { AISDK } from "../aisdk" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" +import { Credential } from "../credential" +import { Integration } from "../integration" +import { ModelV2 } from "../model" +import { PluginV2 } from "../plugin" +import { ProviderV2 } from "../provider" +import { Reference } from "../reference" +import type { DeepMutable } from "../schema" +import { SkillV2 } from "../skill" + +const mutable = (value: T) => value as DeepMutable + +export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { + const agents = yield* AgentV2.Service + const aisdk = yield* AISDK.Service + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const integration = yield* Integration.Service + const reference = yield* Reference.Service + const skill = yield* SkillV2.Service + + return { + options: {}, + agent: { + reload: agents.reload, + transform: (callback) => + agents.transform((draft) => + callback({ + list: () => mutable(draft.list()), + get: (id) => mutable(draft.get(AgentV2.ID.make(id))), + default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), + update: (id, update) => draft.update(AgentV2.ID.make(id), update), + remove: (id) => draft.remove(AgentV2.ID.make(id)), + }), + ), + }, + aisdk: { + sdk: (callback) => + aisdk.hook.sdk((event) => { + const output = { + model: mutable(event.model), + package: event.package, + options: event.options, + sdk: event.sdk, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }), + language: (callback) => + aisdk.hook.language((event) => { + const output = { + model: mutable(event.model), + sdk: event.sdk, + options: event.options, + language: event.language, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.language = output.language))), + ) + }), + }, + catalog: { + reload: catalog.reload, + transform: (callback) => + catalog.transform((draft) => + callback({ + provider: { + list: () => mutable(draft.provider.list()), + get: (id) => mutable(draft.provider.get(ProviderV2.ID.make(id))), + update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update), + remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)), + }, + model: { + get: (providerID, modelID) => + mutable(draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))), + update: (providerID, modelID, update) => + draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update), + remove: (providerID, modelID) => + draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + default: { + get: draft.model.default.get, + set: (providerID, modelID) => + draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + }, + }, + }), + ), + }, + command: { + reload: commands.reload, + transform: commands.transform, + }, + integration: { + reload: integration.reload, + connection: { + active: (id) => integration.connection.active(Integration.ID.make(id)), + resolve: (connection) => + integration.connection.resolve( + connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, + ), + }, + transform: (callback) => + integration.transform((draft) => + callback({ + list: () => mutable(draft.list()), + get: (id) => mutable(draft.get(Integration.ID.make(id))), + update: (id, update) => draft.update(Integration.ID.make(id), update), + remove: (id) => draft.remove(Integration.ID.make(id)), + method: { + list: (id) => mutable(draft.method.list(Integration.ID.make(id))), + update: (input) => { + if ("authorize" in input) { + const methodID = Integration.MethodID.make(input.method.id) + const refresh = input.refresh + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: methodID }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: authorization.callback.pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + } + return { + ...authorization, + callback: (code: string) => + authorization.callback(code).pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + }), + ), + ...(refresh + ? { + refresh: (value: Credential.OAuth) => + refresh(value).pipe( + Effect.map((next) => + Credential.OAuth.make({ + ...next, + methodID: Integration.MethodID.make(next.methodID), + }), + ), + ), + } + : {}), + ...(input.label ? { label: input.label } : {}), + }) + return + } + if (input.method.type === "env") { + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "env", names: input.method.names }, + }) + return + } + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "key", label: input.method.label }, + }) + }, + remove: (id, method) => + draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), + }, + }), + ), + }, + plugin: { + add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), + remove: (id) => plugin.remove(PluginV2.ID.make(id)), + }, + reference: { + reload: reference.reload, + transform: (callback) => + reference.transform((draft) => + callback({ + add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)), + remove: draft.remove, + list: draft.list, + }), + ), + }, + skill: { + reload: skill.reload, + transform: (callback) => + skill.transform((draft) => + callback({ + source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)), + list: draft.list, + }), + ), + }, + } satisfies Interface +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts new file mode 100644 index 00000000000..80193452992 --- /dev/null +++ b/packages/core/src/plugin/internal.ts @@ -0,0 +1,152 @@ +export * as PluginInternal from "./internal" + +import { makeLocationNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" +import type { PluginContext } from "@kilocode/plugin/v2/effect" +import { Effect, Layer, Scope } from "effect" +import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" +import { Config } from "../config" +import { ConfigAgentPlugin } from "../config/plugin/agent" +import { ConfigCommandPlugin } from "../config/plugin/command" +import { ConfigExternalPlugin } from "../config/plugin/external" +import { ConfigProviderPlugin } from "../config/plugin/provider" +import { ConfigReferencePlugin } from "../config/plugin/reference" +import { ConfigSkillPlugin } from "../config/plugin/skill" +import { EventV2 } from "../event" +import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { Integration } from "../integration" +import { Location } from "../location" +import { ModelsDev } from "../models-dev" +import { Npm } from "../npm" +import { PluginV2 } from "../plugin" +import { Reference } from "../reference" +import { SkillV2 } from "../skill" +import { State } from "../state" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { AgentPlugin } from "./agent" +import { CommandPlugin } from "./command" +import { ModelsDevPlugin } from "./models-dev" +import { ProviderPlugins } from "./provider" +import { VariantPlugin } from "./variant" + +export type Requirements = + | AgentV2.Service + | Catalog.Service + | CommandV2.Service + | Config.Service + | EventV2.Service + | FileSystem.Service + | FSUtil.Service + | Global.Service + | HttpClient.HttpClient + | Integration.Service + | Location.Service + | ModelsDev.Service + | Npm.Service + | Reference.Service + | SkillV2.Service + +export interface Plugin { + readonly id: string + readonly effect: (context: PluginContext) => Effect.Effect +} + +export function define(plugin: Plugin) { + return plugin +} + +const layer = Layer.effectDiscard( + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const plugin = yield* PluginV2.Service + const integration = yield* Integration.Service + const agents = yield* AgentV2.Service + const config = yield* Config.Service + const location = yield* Location.Service + const modelsDev = yield* ModelsDev.Service + const npm = yield* Npm.Service + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const filesystem = yield* FileSystem.Service + const global = yield* Global.Service + const http = yield* HttpClient.HttpClient + const skill = yield* SkillV2.Service + const reference = yield* Reference.Service + const add = (input: Plugin) => { + const loaded = { + id: input.id, + effect: (context: PluginContext) => + input + .effect(context) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(HttpClient.HttpClient, http), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), + } + return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) + } + + yield* State.batch( + Effect.gen(function* () { + yield* add(ConfigReferencePlugin.Plugin) + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + // kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill. + yield* add(ModelsDevPlugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(VariantPlugin.Plugin) + }), + ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + }), +) + +export const locationLayer = layer.pipe( + Layer.provideMerge(Config.locationLayer), + Layer.provideMerge(FetchHttpClient.layer), +) + +export const node = makeLocationNode({ + name: "plugin-internal", + layer, + deps: [ + Catalog.node, + CommandV2.node, + PluginV2.node, + Integration.node, + AgentV2.node, + Config.node, + Location.node, + ModelsDev.node, + Npm.node, + EventV2.node, + FSUtil.node, + FileSystem.node, + Global.node, + httpClient, + SkillV2.node, + Reference.node, + ], +}) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index a212d013ad1..eda21e39b0f 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,19 +1,16 @@ -import { DateTime, Effect, Scope, Stream } from "effect" -import { Catalog } from "../catalog" -import { Integration } from "../integration" +import { define } from "./internal" +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import { Effect, Stream } from "effect" import { EventV2 } from "../event" -import { ModelV2 } from "../model" -import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" -import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" function released(date: string) { const time = Date.parse(date) - return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0) + return Number.isFinite(time) ? time : 0 } -function cost(input: ModelsDev.Model["cost"]) { +function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { const base = { input: input?.input ?? 0, output: input?.output ?? 0, @@ -22,51 +19,114 @@ function cost(input: ModelsDev.Model["cost"]) { write: input?.cache_write ?? 0, }, } - if (!input?.context_over_200k) return [base] return [ base, - { - tier: { - type: "context" as const, - size: 200_000, - }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, + ...(input?.tiers?.map((item) => ({ + tier: item.tier, + input: item.input, + output: item.output, cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: item.cache_read ?? 0, + write: item.cache_write ?? 0, }, - }, + })) ?? []), + ...(input?.context_over_200k + ? [ + { + tier: { + type: "context" as const, + size: 200_000, + }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? 0, + write: input.context_over_200k.cache_write ?? 0, + }, + }, + ] + : []), ] } -function variants(model: ModelsDev.Model, packageName?: string) { - return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => { - const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {}) - return { - id: ModelV2.VariantID.make(id), - headers: { ...(item.provider?.headers ?? {}) }, - ...request, - } +function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) { + if (!override) return base + const next = cost(override) + const [baseDefault, ...baseTiers] = base + const [nextDefault, ...nextTiers] = next + const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({ + ...left, + ...right, + tier: right.tier ?? left.tier, + cache: { ...left.cache, ...right.cache }, }) + const tiers = new Map(baseTiers.map((item) => [tierKey(item), item])) + for (const item of nextTiers) { + const current = tiers.get(tierKey(item)) + tiers.set(tierKey(item), current ? merge(current, item) : item) + } + return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] } -export const ModelsDevPlugin = PluginV2.define({ - id: PluginV2.ID.make("models-dev"), - effect: Effect.gen(function* () { - const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service +function modeName(model: ModelsDev.Model, mode: string) { + return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` +} + +function applyModel( + draft: ModelV2Info, + model: ModelsDev.Model, + input: { + readonly name?: string + readonly cost?: ModelV2Info["cost"] + readonly request?: NonNullable["modes"]>[string]["provider"] + } = {}, +) { + draft.name = input.name ?? model.name + draft.family = model.family + draft.api = model.provider?.npm + ? { + id: model.id, + type: "aisdk", + package: model.provider.npm, + url: model.provider.api, + } + : { + id: model.id, + type: "native", + url: model.provider?.api, + settings: {}, + } + draft.capabilities = { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + } + draft.variants = [] + draft.time.released = released(model.release_date) + draft.cost = input.cost ?? cost(model.cost) + draft.status = model.status ?? "active" + draft.enabled = true + draft.limit = { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + } + Object.assign(draft.request.headers, input.request?.headers ?? {}) + Object.assign(draft.request.body, input.request?.body ?? {}) +} + +export const ModelsDevPlugin = define({ + id: "models-dev", + effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service - const scope = yield* Scope.Scope - const transform = yield* catalog.transform() - const integrationTransform = yield* integrations.transform() - const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { - const data = yield* modelsDev.get() - yield* integrationTransform((integrations) => { + yield* ctx.integration.transform( + Effect.fn(function* (integrations) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { if (item.env.length === 0) continue - const integrationID = Integration.ID.make(item.id) + const integrationID = item.id integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.method.update({ integrationID, @@ -77,8 +137,11 @@ export const ModelsDevPlugin = PluginV2.define({ method: { type: "env", names: [...item.env] }, }) } - }) - yield* transform((catalog) => { + }), + ) + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { @@ -97,46 +160,23 @@ export const ModelsDevPlugin = PluginV2.define({ }) for (const model of Object.values(item.models)) { - const modelID = ModelV2.ID.make(model.id) - catalog.model.update(providerID, modelID, (draft) => { - draft.name = model.name - draft.family = model.family ? ModelV2.Family.make(model.family) : undefined - draft.api = model.provider?.npm - ? { - id: draft.api.id, - type: "aisdk", - package: model.provider?.npm, - url: model.provider.api, - } - : { - id: draft.api.id, - type: "native", - url: model.provider?.api, - settings: {}, - } - draft.capabilities = { - tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } - draft.variants = variants(model, model.provider?.npm ?? item.npm) - draft.time.released = released(model.release_date) - draft.cost = cost(model.cost) - draft.status = model.status ?? "active" - draft.enabled = true - draft.limit = { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - } - }) + const baseCost = cost(model.cost) + catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost })) + for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { + catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => + applyModel(draft, model, { + name: modeName(model, mode), + cost: mergeCost(baseCost, options.cost), + request: options.provider, + }), + ) + } } } - }) - }) - yield* refresh() + }), + ) yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( - Stream.runForEach(() => refresh()), + Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))), Effect.forkScoped({ startImmediately: true }), ) }), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts new file mode 100644 index 00000000000..01928b41657 --- /dev/null +++ b/packages/core/src/plugin/promise.ts @@ -0,0 +1,93 @@ +export * as PluginPromise from "./promise" + +import { define } from "@kilocode/plugin/v2/effect" +import type { Plugin, PluginContext, Registration } from "@kilocode/plugin/v2/promise" +import { Effect, Scope } from "effect" + +// The Effect host hands back this registration shape; mirror it structurally so +// we do not have to alias the Effect package's `Registration` against the Promise one. +type HostRegistration = { readonly dispose: Effect.Effect } + +/** + * Adapts a Promise plugin into an Effect plugin so the existing Effect-only + * loader (`PluginV2` / `PluginInternal`) can run it unchanged. + * + * Hook registrations created during the async `setup` attach to the plugin's + * scope, so unloading the plugin disposes them. The captured fiber context + * preserves boot-time batching, so Promise-plugin transforms still coalesce + * into one reload per domain. + */ +export function fromPromise(plugin: Plugin) { + return define({ + id: plugin.id, + effect: (host) => + Effect.gen(function* () { + const scope = yield* Scope.Scope + const context = yield* Effect.context() + + // Run a hook registration on the plugin scope and resolve once it is registered. + const register = (effect: Effect.Effect): Promise => + Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({ + dispose: () => Effect.runPromiseWith(context)(registration.dispose), + })) + + const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) + + const transform = + (domain: { + transform: ( + callback: (draft: Draft) => Effect.Effect | void, + ) => Effect.Effect + }) => + (callback: (draft: Draft) => Promise | void) => + register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft))))) + + const context2: PluginContext = { + options: host.options, + agent: { + transform: transform(host.agent), + reload: () => run(host.agent.reload()), + }, + aisdk: { + sdk: (callback) => + register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), + language: (callback) => + register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), + }, + catalog: { + transform: transform(host.catalog), + reload: () => run(host.catalog.reload()), + }, + command: { + transform: transform(host.command), + reload: () => run(host.command.reload()), + }, + integration: { + transform: transform(host.integration), + reload: () => run(host.integration.reload()), + connection: { + active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)), + resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)), + }, + }, + plugin: { + add: (input) => { + const child = fromPromise(input) + return run(host.plugin.add(child)) + }, + remove: (id) => run(host.plugin.remove(id)), + }, + reference: { + transform: transform(host.reference), + reload: () => run(host.reference.reload()), + }, + skill: { + transform: transform(host.skill), + reload: () => run(host.skill.reload()), + }, + } + + yield* Effect.promise(() => Promise.resolve(plugin.setup(context2))) + }), + }) +} diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index ea3939b750d..1749b474ed3 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -30,8 +30,10 @@ import { VercelPlugin } from "./provider/vercel" import { VenicePlugin } from "./provider/venice" import { XAIPlugin } from "./provider/xai" import { ZenmuxPlugin } from "./provider/zenmux" +import type { PluginInternal } from "./internal" +import type { Scope } from "effect" -export const ProviderPlugins = [ +export const ProviderPlugins: PluginInternal.Plugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index fa5c0a91cfb..c5c4be0d0be 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const AlibabaPlugin = PluginV2.define({ - id: PluginV2.ID.make("alibaba"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const AlibabaPlugin = define({ + id: "alibaba", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) evt.sdk = mod.createAlibaba(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 9c7fd65665a..0995cf1c172 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type MantleSDK = { @@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) { return sdk.responses(modelID) } -export const AmazonBedrockPlugin = PluginV2.define({ - id: PluginV2.ID.make("amazon-bedrock"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AmazonBedrockPlugin = define({ + id: "amazon-bedrock", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue @@ -77,7 +77,9 @@ export const AmazonBedrockPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE @@ -108,7 +110,9 @@ export const AmazonBedrockPlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock")) evt.sdk = mod.createAmazonBedrock(options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { evt.language = selectMantleModel(evt.sdk, evt.model.api.id) @@ -117,6 +121,6 @@ export const AmazonBedrockPlugin = PluginV2.define({ const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 9bd69fe036c..cf883a0687f 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const AnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AnthropicPlugin = define({ + id: "anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/anthropic") continue @@ -15,11 +15,13 @@ export const AnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) evt.sdk = mod.createAnthropic(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 173fd36621f..2e1f9d9b48f 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -10,11 +10,11 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { return sdk.languageModel(modelID) } -export const AzurePlugin = PluginV2.define({ - id: PluginV2.ID.make("azure"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzurePlugin = define({ + id: "azure", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/azure") continue @@ -27,7 +27,9 @@ export const AzurePlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { if ( @@ -43,19 +45,21 @@ export const AzurePlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) evt.sdk = mod.createAzure(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) -export const AzureCognitiveServicesPlugin = PluginV2.define({ - id: PluginV2.ID.make("azure-cognitive-services"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzureCognitiveServicesPlugin = define({ + id: "azure-cognitive-services", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return for (const item of evt.provider.list()) { @@ -67,10 +71,12 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ }) } }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index f8719436873..0fd651160fe 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,24 +1,26 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const CerebrasPlugin = PluginV2.define({ - id: PluginV2.ID.make("cerebras"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (ctx) { - for (const item of ctx.provider.list()) { +export const CerebrasPlugin = define({ + id: "cerebras", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/cerebras") continue - ctx.provider.update(item.provider.id, (provider) => { + evt.provider.update(item.provider.id, (provider) => { provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) evt.sdk = mod.createCerebras(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index ba7856b6357..d416f6f19d3 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,13 +1,13 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const CloudflareAIGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-ai-gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CloudflareAIGatewayPlugin = define({ + id: "cloudflare-ai-gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return @@ -31,7 +31,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({ }, } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 10f3f5200a9..1a1c533eb55 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,16 +1,16 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") -export const CloudflareWorkersAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-workers-ai"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const CloudflareWorkersAIPlugin = define({ + id: "cloudflare-workers-ai", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const item = evt.provider.get(providerID) if (!item) return evt.provider.update(item.provider.id, (provider) => { @@ -20,7 +20,9 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ if (accountId) provider.api.url = workersEndpoint(accountId) }) }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -34,11 +36,13 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ }) as any, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 991c370d175..0ca0708577a 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const CoherePlugin = PluginV2.define({ - id: PluginV2.ID.make("cohere"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CoherePlugin = define({ + id: "cohere", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) evt.sdk = mod.createCohere(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index bbd42f6e283..1b23e08ba4a 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const DeepInfraPlugin = PluginV2.define({ - id: PluginV2.ID.make("deepinfra"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const DeepInfraPlugin = define({ + id: "deepinfra", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) evt.sdk = mod.createDeepInfra(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index e5abc7009e3..c84a6ed51f8 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -1,19 +1,19 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" +import { Npm } from "../../npm" -export const DynamicProviderPlugin = PluginV2.define({ - id: PluginV2.ID.make("dynamic-provider"), - effect: Effect.gen(function* () { +export const DynamicProviderPlugin = define({ + id: "dynamic-provider", + effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.sdk) return const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -26,6 +26,6 @@ export const DynamicProviderPlugin = PluginV2.define({ evt.sdk = mod[match](evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 5b08ad9ef5e..f097dcaca3f 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const GatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GatewayPlugin = define({ + id: "gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) evt.sdk = mod.createGateway(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 1fc7c0c7999..682579d7a99 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function shouldUseResponses(modelID: string) { @@ -11,16 +11,29 @@ function shouldUseResponses(modelID: string) { return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") } -export const GithubCopilotPlugin = PluginV2.define({ - id: PluginV2.ID.make("github-copilot"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GithubCopilotPlugin = define({ + id: "github-copilot", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + const item = evt.provider.get(ProviderV2.ID.githubCopilot) + if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) + }), + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { evt.language = evt.sdk.languageModel(evt.model.api.id) @@ -30,15 +43,6 @@ export const GithubCopilotPlugin = PluginV2.define({ ? evt.sdk.responses(evt.model.api.id) : evt.sdk.chat(evt.model.api.id) }), - "catalog.transform": Effect.fn(function* (evt) { - const item = evt.provider.get(ProviderV2.ID.githubCopilot) - if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // This chat-only alias conflicts with the Copilot GPT-5 Responses route, - // so hide it only for Copilot rather than for every provider catalog. - model.enabled = false - }) - }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 9de090a95d6..8723cdaac2e 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,14 +1,14 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" -export const GitLabPlugin = PluginV2.define({ - id: PluginV2.ID.make("gitlab"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GitLabPlugin = define({ + id: "gitlab", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) evt.sdk = mod.createGitLab({ @@ -30,7 +30,9 @@ export const GitLabPlugin = PluginV2.define({ }, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} @@ -58,6 +60,6 @@ export const GitLabPlugin = PluginV2.define({ featureFlags, }) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index a7168d59add..4e643c9f519 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function resolveProject(options: Record) { @@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { } } -export const GoogleVertexPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexPlugin = define({ + id: "google-vertex", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if ( @@ -83,7 +83,9 @@ export const GoogleVertexPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) return @@ -100,19 +102,21 @@ export const GoogleVertexPlugin = PluginV2.define({ location, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) -export const GoogleVertexAnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex-anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexAnthropicPlugin = define({ + id: "google-vertex-anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue @@ -132,7 +136,9 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) const project = @@ -156,10 +162,12 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ : {}), }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 47e29c6b5d5..476af5b9121 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const GooglePlugin = PluginV2.define({ - id: PluginV2.ID.make("google"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GooglePlugin = define({ + id: "google", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) evt.sdk = mod.createGoogleGenerativeAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index f2052afd1a8..0bddb443095 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const GroqPlugin = PluginV2.define({ - id: PluginV2.ID.make("groq"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GroqPlugin = define({ + id: "groq", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) evt.sdk = mod.createGroq(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index bf6666c2521..5b7404947c8 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,15 +1,15 @@ import { createKilo, KILO_OPENROUTER_BASE } from "@kilocode/kilo-gateway" // kilocode_change import { Effect } from "effect" -import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" // kilocode_change +import { define } from "../internal" -const id = ProviderV2.ID.make("kilo") // kilocode_change +const id = ProviderV2.ID.kilo // kilocode_change -export const KiloPlugin = PluginV2.define({ - id: PluginV2.ID.make("kilo"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const KiloPlugin = define({ + id: "kilo", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.id !== id) continue // kilocode_change evt.provider.update(item.provider.id, (provider) => { @@ -34,12 +34,14 @@ export const KiloPlugin = PluginV2.define({ }) } }), - // kilocode_change start - "aisdk.sdk": Effect.fn(function* (evt) { + ) + // kilocode_change start + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== id) return evt.sdk = createKilo(evt.options) }), - // kilocode_change end - } + ) + // kilocode_change end }), }) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index b416abd2848..12416fd63c2 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,21 +1,21 @@ import { Effect } from "effect" +import { define } from "../internal" import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" // kilocode_change -export const LLMGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("llmgateway"), - effect: Effect.gen(function* () { +export const LLMGatewayPlugin = define({ + id: "llmgateway", + effect: Effect.fn(function* (ctx) { const integrations = yield* Integration.Service - return { - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.disabled) continue - if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue if (item.provider.id !== ProviderV2.ID.make("llmgateway")) continue // kilocode_change + if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change start @@ -25,6 +25,6 @@ export const LLMGatewayPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index e7f0decb79e..a7319756598 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const MistralPlugin = PluginV2.define({ - id: PluginV2.ID.make("mistral"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const MistralPlugin = define({ + id: "mistral", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) evt.sdk = mod.createMistral(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 94e8163a5d0..d27db3ad061 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" // kilocode_change -export const NvidiaPlugin = PluginV2.define({ - id: PluginV2.ID.make("nvidia"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const NvidiaPlugin = define({ + id: "nvidia", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -21,6 +21,6 @@ export const NvidiaPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai-auth.ts b/packages/core/src/plugin/provider/openai-auth.ts deleted file mode 100644 index fb9e8d300aa..00000000000 --- a/packages/core/src/plugin/provider/openai-auth.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { createServer } from "node:http" -import { Deferred, Effect } from "effect" -import { Integration } from "../../integration" -import { Credential } from "../../credential" -import { InstallationVersion } from "../../installation/version" - -const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" -const issuer = "https://auth.openai.com" -const callbackPort = 1455 -const pollingSafetyMargin = 3000 - -type Pkce = { - verifier: string - challenge: string -} - -type TokenResponse = { - id_token: string - access_token: string - refresh_token: string - expires_in?: number -} - -type Claims = { - chatgpt_account_id?: string - organizations?: Array<{ id: string }> - "https://api.openai.com/auth"?: { chatgpt_account_id?: string } -} - -const browserMethodID = Integration.MethodID.make("chatgpt-browser") -const headlessMethodID = Integration.MethodID.make("chatgpt-headless") - -export const browser = { - integrationID: Integration.ID.make("openai"), - method: { - id: browserMethodID, - type: "oauth", - label: "ChatGPT Pro/Plus (browser)", - }, - authorize: () => - Effect.gen(function* () { - const pkce = yield* Effect.promise(generatePKCE) - const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) - const code = yield* Deferred.make() - const redirect = `http://localhost:${callbackPort}/auth/callback` - const server = createServer((request, response) => { - const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) - if (url.pathname !== "/auth/callback") { - response.writeHead(404).end("Not found") - return - } - // kilocode_change start - unrelated localhost requests must not terminate the active OAuth attempt - if (url.searchParams.get("state") !== state) { - response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage("Invalid OAuth state")) - return - } - // kilocode_change end - const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") - const value = url.searchParams.get("code") - if (error) { - Effect.runFork(Deferred.fail(code, new Error(error))) - response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error)) - return - } - if (!value) { - const message = "Missing authorization code" - Effect.runFork(Deferred.fail(code, new Error(message))) - response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message)) - return - } - Effect.runFork(Deferred.succeed(code, value)) - response.writeHead(200, { "Content-Type": "text/html" }).end(successPage) - }) - yield* Effect.callback((resume) => { - server.once("error", (error) => resume(Effect.fail(error))) - server.listen(callbackPort, "localhost", () => resume(Effect.void)) - }) - yield* Effect.addFinalizer(() => - Effect.sync(() => { - server.close() - }), - ) - return { - mode: "auto" as const, - url: authorizeURL(redirect, pkce, state), - instructions: "Complete authorization in your browser. This window will close automatically.", - callback: Deferred.await(code).pipe( - Effect.flatMap((value) => exchange(value, redirect, pkce)), - Effect.map((tokens) => credential(browserMethodID, tokens)), - ), - } - }), - refresh: (value) => refresh(value), -} satisfies Integration.OAuthImplementation - -export const headless = { - integrationID: Integration.ID.make("openai"), - method: { - id: headlessMethodID, - type: "oauth", - label: "ChatGPT Pro/Plus (headless)", - }, - authorize: () => - Effect.gen(function* () { - const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( - `${issuer}/api/accounts/deviceauth/usercode`, - { - method: "POST", - headers: headers("application/json"), - body: JSON.stringify({ client_id: clientID }), - }, - ) - const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 - return { - mode: "auto" as const, - url: `${issuer}/codex/device`, - instructions: `Enter code: ${device.user_code}`, - callback: Effect.gen(function* () { - while (true) { - const response = yield* Effect.tryPromise({ - try: (signal) => - fetch(`${issuer}/api/accounts/deviceauth/token`, { - method: "POST", - headers: headers("application/json"), - body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), - signal, - }), - catch: (cause) => cause, - }) - if (response.ok) { - const data = (yield* Effect.promise(() => response.json())) as { - authorization_code: string - code_verifier: string - } - return credential( - headlessMethodID, - yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { - verifier: data.code_verifier, - challenge: "", - }), - ) - } - if (response.status !== 403 && response.status !== 404) { - return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) - } - yield* Effect.sleep(interval + pollingSafetyMargin) - } - }), - } - }), - refresh: (value) => refresh(value), -} satisfies Integration.OAuthImplementation - -function headers(contentType: string) { - return { "Content-Type": contentType, "User-Agent": `kilo/${InstallationVersion}` } // kilocode_change -} - -function exchange(code: string, redirect: string, pkce: Pkce) { - return request(`${issuer}/oauth/token`, { - method: "POST", - headers: headers("application/x-www-form-urlencoded"), - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirect, - client_id: clientID, - code_verifier: pkce.verifier, - }).toString(), - }) -} - -function refresh(value: Credential.OAuth) { - return request(`${issuer}/oauth/token`, { - method: "POST", - headers: headers("application/x-www-form-urlencoded"), - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: value.refresh, - client_id: clientID, - }).toString(), - }).pipe( - Effect.map((tokens) => { - const next = credential(value.methodID, tokens) - return new Credential.OAuth({ - ...next, - metadata: next.metadata ?? value.metadata, - }) - }), - ) -} - -function request(url: string, init: RequestInit) { - return Effect.tryPromise({ - try: async (signal) => { - const response = await fetch(url, { ...init, signal }) - if (!response.ok) throw new Error(`Request failed: ${response.status}`) - return response.json() as Promise - }, - catch: (cause) => cause, - }) -} - -function credential(methodID: Integration.MethodID, tokens: TokenResponse) { - const accountID = extractAccountID(tokens) - return new Credential.OAuth({ - type: "oauth", - methodID, - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, - metadata: accountID ? { accountID } : undefined, - }) -} - -async function generatePKCE(): Promise { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("") - const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) - return { verifier, challenge } -} - -function base64UrlEncode(buffer: ArrayBuffer) { - return Buffer.from(buffer).toString("base64url") -} - -function authorizeURL(redirect: string, pkce: Pkce, state: string) { - return `${issuer}/oauth/authorize?${new URLSearchParams({ - response_type: "code", - client_id: clientID, - redirect_uri: redirect, - scope: "openid profile email offline_access", - code_challenge: pkce.challenge, - code_challenge_method: "S256", - id_token_add_organizations: "true", - codex_cli_simplified_flow: "true", - state, - originator: "kilo", // kilocode_change - })}` -} - -function extractAccountID(tokens: TokenResponse) { - return claim(tokens.id_token) ?? claim(tokens.access_token) -} - -function claim(token: string) { - const part = token.split(".")[1] - if (!part) return - try { - const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims - return ( - claims.chatgpt_account_id ?? - claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? - claims.organizations?.[0]?.id - ) - } catch { - return - } -} - -const successPage = - "Kilo

Authorization successful

You can close this window.

" -const errorPage = (message: string) => - `Kilo

Authorization failed

${message.replace(/[&<>"']/g, "")}

` diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index 76c33737066..d602ed0ff95 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -1,17 +1,17 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const OpenAICompatiblePlugin = PluginV2.define({ - id: PluginV2.ID.make("openai-compatible"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const OpenAICompatiblePlugin = define({ + id: "openai-compatible", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return if (evt.options.includeUsage !== false) evt.options.includeUsage = true const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) evt.sdk = mod.createOpenAICompatible(evt.options as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index d58bd784f52..2981dc0b9f0 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,29 +1,173 @@ -import { Effect } from "effect" -import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" -import { ProviderV2 } from "../../provider" +import { createServer } from "node:http" +import type { IntegrationOAuthMethodRegistration } from "@kilocode/plugin/v2/effect/integration" +import { define } from "@kilocode/plugin/v2/effect/plugin" +import { Deferred, Effect } from "effect" +import type { Scope } from "effect" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" import { Integration } from "../../integration" -import { browser, headless } from "./openai-auth" +import { KiloOauthCallbackPage } from "../../kilocode/oauth/page" // kilocode_change +import { ModelV2 } from "../../model" +import { ProviderV2 } from "../../provider" +import type { PluginInternal } from "../internal" -export const OpenAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("openai"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service - yield* integrations.update((editor) => { - editor.method.update(browser) - editor.method.update(headless) +const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" +const issuer = "https://auth.openai.com" +const callbackPort = 1455 +const pollingSafetyMargin = 3000 +const browserMethodID = Integration.MethodID.make("chatgpt-browser") +const headlessMethodID = Integration.MethodID.make("chatgpt-headless") + +type Pkce = { + verifier: string + challenge: string +} + +type TokenResponse = { + id_token: string + access_token: string + refresh_token: string + expires_in?: number +} + +type Claims = { + chatgpt_account_id?: string + organizations?: Array<{ id: string }> + "https://api.openai.com/auth"?: { chatgpt_account_id?: string } +} + +const browser = { + integrationID: Integration.ID.make("openai"), + method: { + id: browserMethodID, + type: "oauth", + label: "ChatGPT Pro/Plus (browser)", + }, + authorize: () => + Effect.gen(function* () { + const pkce = yield* Effect.promise(generatePKCE) + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) + const code = yield* Deferred.make() + const redirect = `http://localhost:${callbackPort}/auth/callback` + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) + if (url.pathname !== "/auth/callback") { + response.writeHead(404).end("Not found") + return + } + // kilocode_change start - unrelated localhost requests must not terminate the active OAuth attempt + if (url.searchParams.get("state") !== state) { + response.writeHead(400, { "Content-Type": "text/html" }).end(KiloOauthCallbackPage.error("Invalid OAuth state")) + return + } + // kilocode_change end + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(KiloOauthCallbackPage.error(error, { provider: "ChatGPT" })) // kilocode_change + return + } + if (!value) { + const message = "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(KiloOauthCallbackPage.error(message, { provider: "ChatGPT" })) // kilocode_change + return + } + Effect.runFork(Deferred.succeed(code, value)) + response + .writeHead(200, { "Content-Type": "text/html" }) + .end(KiloOauthCallbackPage.success({ provider: "ChatGPT" })) // kilocode_change + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, "localhost", () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + return { + mode: "auto" as const, + url: authorizeURL(redirect, pkce, state), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => exchange(value, redirect, pkce)), + Effect.map((tokens) => credential(browserMethodID, tokens)), + ), + } + }), + refresh: (value) => refresh(browserMethodID, value), +} satisfies IntegrationOAuthMethodRegistration + +const headless = { + integrationID: Integration.ID.make("openai"), + method: { + id: headlessMethodID, + type: "oauth", + label: "ChatGPT Pro/Plus (headless)", + }, + authorize: () => + Effect.gen(function* () { + const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( + `${issuer}/api/accounts/deviceauth/usercode`, + { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ client_id: clientID }), + }, + ) + const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 + return { + mode: "auto" as const, + url: `${issuer}/codex/device`, + instructions: `Enter code: ${device.user_code}`, + callback: Effect.gen(function* () { + while (true) { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetch(`${issuer}/api/accounts/deviceauth/token`, { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), + signal, + }), + catch: (cause) => cause, + }) + if (response.ok) { + const data = (yield* Effect.promise(() => response.json())) as { + authorization_code: string + code_verifier: string + } + return credential( + headlessMethodID, + yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { + verifier: data.code_verifier, + challenge: "", + }), + ) + } + if (response.status !== 403 && response.status !== 404) { + return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) + } + yield* Effect.sleep(interval + pollingSafetyMargin) + } + }), + } + }), + refresh: (value) => refresh(headlessMethodID, value), +} satisfies IntegrationOAuthMethodRegistration + +export const OpenAIPlugin = define({ + id: "openai", + effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.method.update(browser) + draft.method.update(headless) }) - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (evt.package !== "@ai-sdk/openai") return - const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) - evt.sdk = mod.createOpenAI(evt.options) - }), - "aisdk.language": Effect.fn(function* (evt) { - if (evt.model.providerID !== ProviderV2.ID.openai) return - evt.language = evt.sdk.responses(evt.model.api.id) - }), - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai") continue @@ -35,6 +179,122 @@ export const OpenAIPlugin = PluginV2.define({ }) } }), - } + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/openai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) + evt.sdk = mod.createOpenAI(evt.options) + }), + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openai) return + evt.language = evt.sdk.responses(evt.model.api.id) + }), + ) }), -}) +} satisfies PluginInternal.Plugin) + +function headers(contentType: string) { + return { "Content-Type": contentType, "User-Agent": `kilo/${InstallationVersion}` } // kilocode_change +} + +function exchange(code: string, redirect: string, pkce: Pkce) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirect, + client_id: clientID, + code_verifier: pkce.verifier, + }).toString(), + }) +} + +function refresh(methodID: Integration.MethodID, value: Pick) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID, + }).toString(), + }).pipe( + Effect.map((tokens) => { + const next = credential(methodID, tokens) + return Credential.OAuth.make({ ...next, metadata: next.metadata ?? value.metadata }) + }), + ) +} + +function request
(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) throw new Error(`Request failed: ${response.status}`) + return response.json() as Promise + }, + catch: (cause) => cause, + }) +} + +function credential(methodID: Integration.MethodID, tokens: TokenResponse) { + const accountID = extractAccountID(tokens) + return Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + metadata: accountID ? { accountID } : undefined, + }) +} + +async function generatePKCE(): Promise { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("") + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} + +function authorizeURL(redirect: string, pkce: Pkce, state: string) { + return `${issuer}/oauth/authorize?${new URLSearchParams({ + response_type: "code", + client_id: clientID, + redirect_uri: redirect, + scope: "openid profile email offline_access", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + state, + originator: "kilo", // kilocode_change + })}` +} + +function extractAccountID(tokens: TokenResponse) { + return claim(tokens.id_token) ?? claim(tokens.access_token) +} + +function claim(token: string) { + const part = token.split(".")[1] + if (!part) return + try { + const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims + return ( + claims.chatgpt_account_id ?? + claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? + claims.organizations?.[0]?.id + ) + } catch { + return + } +} diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 56e71f822dd..3653e2cb8b0 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,32 +1,44 @@ import { Effect } from "effect" -import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" +import { define } from "@kilocode/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" -export const OpencodePlugin = PluginV2.define({ - id: PluginV2.ID.make("opencode"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service - let hasKey = false - return { - "catalog.transform": Effect.fn(function* (evt) { - const item = evt.provider.get(ProviderV2.ID.opencode) +// kilocode_change start - Kilo keeps only the free-tier catalog gate from upstream's opencode plugin. +// +// Upstream turned this plugin into a full identity + remote-config integration: an OAuth device +// flow against https://console.opencode.ai (client id "opencode-cli"), an "OpenCode Console +// account" login method, an "API key (service account)" method, and a fetch that lets that console +// drive Kilo's provider/model catalog. Kilo routes providers through the Kilo gateway and does not +// offer a competitor's account system as a sign-in option, so none of that is registered here. +// +// What remains is the behavior Kilo actually relies on and shipped before the v1.17.13 merge: +// gate the opencode ("zen") provider's paid models unless the user supplies a key, and mark the +// provider as "public" otherwise. The provider itself still reaches the catalog through models.dev +// sync (see catalog.ts), so this gate stays live. +// +// Do not restore the console auth flow on future merges without a product decision. +export const OpencodePlugin = define({ + id: "opencode", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const item = catalog.provider.get(ProviderV2.ID.opencode) if (!item) return - const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) - hasKey = Boolean( - process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, - ) - evt.provider.update(item.provider.id, (provider) => { + // Read inside the transform so catalog reloads see current credentials, not a boot-time snapshot. + // A connection (env method, service-account key, ...) counts as credentials, exactly as before the merge. + const connected = (yield* ctx.integration.connection.active("opencode")) !== undefined + const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.request.body.apiKey) + catalog.provider.update(item.provider.id, (provider) => { if (!hasKey) provider.request.body.apiKey = "public" }) if (hasKey) return for (const model of item.models.values()) { if (!model.cost.some((cost) => cost.input > 0)) continue - evt.model.update(item.provider.id, model.id, (draft) => { + catalog.model.update(item.provider.id, model.id, (draft) => { draft.enabled = false }) } }), - } + ) }), }) +// kilocode_change end diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index a27e4288dac..6f671aad5bc 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,13 +1,13 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" // kilocode_change -export const OpenRouterPlugin = PluginV2.define({ - id: PluginV2.ID.make("openrouter"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const OpenRouterPlugin = define({ + id: "openrouter", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue @@ -26,11 +26,13 @@ export const OpenRouterPlugin = PluginV2.define({ } } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) evt.sdk = mod.createOpenRouter(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 2415ab7c1a2..44c1ef2fc0a 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const PerplexityPlugin = PluginV2.define({ - id: PluginV2.ID.make("perplexity"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const PerplexityPlugin = define({ + id: "perplexity", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) evt.sdk = mod.createPerplexity(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 47c8b7eaa8c..8c668d8b414 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -1,15 +1,15 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" +import { Npm } from "../../npm" import { ProviderV2 } from "../../provider" -export const SapAICorePlugin = PluginV2.define({ - id: PluginV2.ID.make("sap-ai-core"), - effect: Effect.gen(function* () { +export const SapAICorePlugin = define({ + id: "sap-ai-core", + effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = process.env.AICORE_SERVICE_KEY ?? @@ -18,7 +18,7 @@ export const SapAICorePlugin = PluginV2.define({ const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -35,10 +35,12 @@ export const SapAICorePlugin = PluginV2.define({ : {}, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 0971f3518d1..788ac63eb03 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise @@ -64,11 +64,11 @@ export function cortexFetch(upstream: FetchLike = fetch) { } } -export const SnowflakeCortexPlugin = PluginV2.define({ - id: PluginV2.ID.make("snowflake-cortex"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const SnowflakeCortexPlugin = define({ + id: "snowflake-cortex", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = process.env.SNOWFLAKE_CORTEX_TOKEN ?? @@ -84,6 +84,6 @@ export const SnowflakeCortexPlugin = PluginV2.define({ fetch: cortexFetch(upstream) as typeof fetch, } as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index b1870f26625..8022e0de668 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const TogetherAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("togetherai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const TogetherAIPlugin = define({ + id: "togetherai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) evt.sdk = mod.createTogetherAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 8a3b950245c..1a602ffd50b 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const VenicePlugin = PluginV2.define({ - id: PluginV2.ID.make("venice"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const VenicePlugin = define({ + id: "venice", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) evt.sdk = mod.createVenice(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 7334f1804f1..1f07f702a43 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" // kilocode_change -export const VercelPlugin = PluginV2.define({ - id: PluginV2.ID.make("vercel"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const VercelPlugin = define({ + id: "vercel", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/vercel") continue @@ -17,11 +17,13 @@ export const VercelPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) evt.sdk = mod.createVercel(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 4e9d53e47a5..8145a3480a0 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,20 +1,22 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" -export const XAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("xai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const XAIPlugin = define({ + id: "xai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 4b505875f6e..327fb1f1619 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" // kilocode_change -export const ZenmuxPlugin = PluginV2.define({ - id: PluginV2.ID.make("zenmux"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const ZenmuxPlugin = define({ + id: "zenmux", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -18,6 +18,6 @@ export const ZenmuxPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 620fdc8b9ab..ea723dd89de 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,25 +2,22 @@ export * as SkillPlugin from "./skill" +import { define } from "./internal" import { Effect } from "effect" -import { PluginV2 } from "../plugin" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("skill"), - effect: Effect.gen(function* () { - const skill = yield* SkillV2.Service - const transform = yield* skill.transform() - - yield* transform((editor) => { - editor.source( - new SkillV2.EmbeddedSource({ +export const Plugin = define({ + id: "skill", + effect: Effect.fn(function* (ctx) { + yield* ctx.skill.transform((draft) => { + draft.source( + SkillV2.EmbeddedSource.make({ type: "embedded", - skill: new SkillV2.Info({ + skill: SkillV2.Info.make({ name: "customize-opencode", description: "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts new file mode 100644 index 00000000000..8576c4304b3 --- /dev/null +++ b/packages/core/src/plugin/variant.ts @@ -0,0 +1,39 @@ +export * as VariantPlugin from "./variant" + +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import { Effect } from "effect" +import { define } from "./internal" + +export const Plugin = define({ + id: "variant", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform((catalog) => { + for (const record of catalog.provider.list()) { + for (const model of record.models.values()) { + catalog.model.update(model.providerID, model.id, (draft) => { + const generated = generate(draft) + if (generated.length === 0) return + + const explicit = new Map(draft.variants.map((variant) => [variant.id, variant])) + const generatedIDs = new Set(generated.map((variant) => variant.id)) + draft.variants = [ + ...generated.map((variant) => explicit.get(variant.id) ?? variant), + ...draft.variants.filter((variant) => !generatedIDs.has(variant.id)), + ] + }) + } + } + }) + }), +}) + +export function generate(model: ModelV2Info): ModelV2Info["variants"] { + if (model.api.type !== "aisdk" || model.api.package !== "@ai-sdk/openai-compatible") return [] + const ids = `${model.id} ${model.api.id}`.toLowerCase() + if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return [] + return ["high", "max"].map((id) => ({ + id, + headers: {}, + body: { reasoning_effort: id }, + })) +} diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts index 9b7438f4ffd..a2adebb54e4 100644 --- a/packages/core/src/policy.ts +++ b/packages/core/src/policy.ts @@ -1,5 +1,6 @@ export * as Policy from "./policy" +import { makeLocationNode } from "./effect/app-node" import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" import { Wildcard } from "./util/wildcard" import { Location } from "./location" @@ -21,7 +22,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Policy") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, EffectRuntime.gen(function* () { let statements: Info[] = [] @@ -44,3 +45,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index 44418d74c1b..16e9118d1f5 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -3,16 +3,24 @@ import type { PlatformError } from "effect/PlatformError" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "./cross-spawn-spawner" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/app-node" export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { command: Schema.String, exitCode: Schema.optional(Schema.Number), stderr: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), -}) {} + cause: Schema.optional(Schema.Defect()), +}) { + override get message() { + const detail = + this.stderr?.trim() || (this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause)) + const status = this.exitCode === undefined ? "" : ` (exit ${this.exitCode})` + return `Command failed${status}: ${this.command}${detail ? `: ${detail}` : ""}` + } +} export interface RunOptions { + readonly combineOutput?: boolean readonly maxOutputBytes?: number readonly maxErrorBytes?: number readonly signal?: AbortSignal @@ -30,8 +38,10 @@ export interface RunStreamOptions { export interface RunResult { readonly command: string readonly exitCode: number + readonly output?: Buffer readonly stdout: Buffer readonly stderr: Buffer + readonly outputTruncated?: boolean readonly stdoutTruncated: boolean readonly stderrTruncated: boolean } @@ -126,7 +136,7 @@ export const collectStream = (stream: Stream.Stream, }, ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner @@ -136,6 +146,22 @@ export const layer = Layer.effect( const collect = Effect.scoped( Effect.gen(function* () { const handle = yield* spawner.spawn(command) + if (options?.combineOutput) { + const [output, exitCode] = yield* Effect.all( + [collectStream(handle.all, options.maxOutputBytes), handle.exitCode], + { concurrency: "unbounded" }, + ) + return { + command: description, + exitCode, + output: output.buffer, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + outputTruncated: output.truncated, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies RunResult + } const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStream(handle.stdout, options?.maxOutputBytes), @@ -230,7 +256,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) -export const node = LayerNode.make(layer, [CrossSpawnSpawner.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] }) export * as AppProcess from "./process" diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 4f941930986..d08222a7246 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -6,7 +6,7 @@ import path from "path" import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" import { Git } from "./git" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/app-node" import { Hash } from "./util/hash" import { ProjectDirectories } from "./project/directories" import { ProjectSchema } from "./project/schema" @@ -51,7 +51,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ProjectV2") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -70,8 +70,8 @@ export const layer = Layer.effect( ) }) - const remote = Effect.fnUntraced(function* (repo: Git.Repo) { - const origin = yield* git.remote(repo) + const remote = Effect.fnUntraced(function* (repo: Git.Repository) { + const origin = yield* git.remote.get(repo) if (!origin) return undefined const normalized = url(origin) if (!normalized) return undefined @@ -102,22 +102,22 @@ export const layer = Layer.effect( return `${host.toLowerCase()}/${pathname}` } - const root = Effect.fnUntraced(function* (repo: Git.Repo) { - const root = (yield* git.roots(repo))[0] + const root = Effect.fnUntraced(function* (repo: Git.Repository) { + const root = (yield* git.history.rootCommits(repo))[0] return root ? ID.make(root) : undefined }) const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { - const repo = yield* git.find(input) + const repo = yield* git.repo.discover(input) if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } - const previous = yield* cached(repo.store) + const previous = yield* cached(repo.commonDirectory) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) return { previous, id: id ?? ID.global, - directory: repo.directory, - vcs: { type: "git" as const, store: repo.store }, + directory: repo.worktree, + vcs: { type: "git" as const, store: repo.commonDirectory }, } }) @@ -129,9 +129,8 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provideMerge(ProjectDirectories.defaultLayer), -) -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node]) +export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, Git.node, ProjectDirectories.node], +}) diff --git a/packages/core/src/project/copy-strategies.ts b/packages/core/src/project/copy-strategies.ts index 1199964f6cf..59466b9901a 100644 --- a/packages/core/src/project/copy-strategies.ts +++ b/packages/core/src/project/copy-strategies.ts @@ -1,4 +1,3 @@ -import path from "path" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { Git } from "../git" @@ -8,28 +7,26 @@ export function makeGitWorktreeStrategy(input: { git: Git.Interface canonical: (directory: AbsolutePath) => Effect.Effect }) { - const repo = (sourceDirectory: AbsolutePath) => - ({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo - return { id: StrategyID.make("git_worktree"), create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) { - yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory }) + const repository = yield* input.git.repo.discover(options.sourceDirectory) + if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory }) + yield* input.git.worktree.create({ repository, directory: options.directory }) return { directory: yield* input.canonical(options.directory) } }), remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) { - const found = yield* input.git.find(options.directory) + const found = yield* input.git.repo.discover(options.directory) if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory }) - yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force }) + yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force }) }), list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) { - const found = yield* input.git.find(directory) + const found = yield* input.git.repo.discover(directory) if (!found) return yield* new DirectoryUnavailableError({ directory }) - const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store - const entries = yield* input.git.worktreeList(found) + const entries = yield* input.git.worktree.list(found) return yield* Effect.forEach(entries, (entry) => - input.canonical(entry).pipe( - Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const), + input.canonical(entry.directory).pipe( + Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const), Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)), ), ).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined))) diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 0e3246b3b2a..5b2f95655af 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -5,7 +5,7 @@ import path from "path" import { AbsolutePath } from "../schema" import { FSUtil } from "../fs-util" import { Git } from "../git" -import { LayerNode } from "../effect/layer-node" +import { makeLocationNode } from "../effect/app-node" import { Project } from "../project" import { ProjectDirectories } from "./directories" import { makeGitWorktreeStrategy } from "./copy-strategies" @@ -13,25 +13,16 @@ import { Slug } from "../util/slug" import { EventV2 } from "../event" import { Database } from "../database/database" import { Location } from "../location" -import { PluginBoot } from "../plugin/boot" +import { Event } from "@opencode-ai/schema/project-directories" +import { ProjectCopy } from "@opencode-ai/schema/project-copy" -export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) +export const StrategyID = ProjectCopy.StrategyID export type StrategyID = typeof StrategyID.Type -export const CreateInput = Schema.Struct({ - projectID: Project.ID, - strategy: StrategyID, - sourceDirectory: AbsolutePath, - directory: AbsolutePath, - name: Schema.optional(Schema.String), -}).annotate({ identifier: "ProjectCopy.CreateInput" }) +export const CreateInput = ProjectCopy.CreateInput export type CreateInput = typeof CreateInput.Type -export const RemoveInput = Schema.Struct({ - projectID: Project.ID, - directory: AbsolutePath, - force: Schema.Boolean, -}).annotate({ identifier: "ProjectCopy.RemoveInput" }) +export const RemoveInput = ProjectCopy.RemoveInput export type RemoveInput = typeof RemoveInput.Type export const RefreshInput = Schema.Struct({ @@ -45,9 +36,7 @@ export const RefreshResult = Schema.Struct({ }).annotate({ identifier: "ProjectCopy.RefreshResult" }) export type RefreshResult = typeof RefreshResult.Type -export const Copy = Schema.Struct({ - directory: AbsolutePath, -}).annotate({ identifier: "ProjectCopy.Copy" }) +export const Copy = ProjectCopy.Copy export type Copy = typeof Copy.Type export const ListEntry = Schema.Struct({ @@ -107,12 +96,7 @@ export interface Strategy { readonly list: (directory: AbsolutePath) => Effect.Effect } -export const Event = { - Updated: EventV2.define({ - type: "project.directories.updated", - schema: { projectID: Project.ID }, - }), -} +export { Event } export interface Interface { readonly register: (strategy: Strategy) => Effect.Effect @@ -125,10 +109,8 @@ export class Service extends Context.Service()("@opencode/Pr export const refreshAfterBoot = Effect.gen(function* () { const location = yield* Location.Service - const boot = yield* PluginBoot.Service const copies = yield* Service yield* Effect.gen(function* () { - yield* boot.wait() yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id }) const result = yield* copies.refresh({ projectID: location.project.id }) yield* Effect.logInfo("project copy refresh done", { @@ -143,7 +125,7 @@ export const refreshAfterBoot = Effect.gen(function* () { ) }) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -297,4 +279,14 @@ export const layer = Layer.effect( ) export const locationLayer = layer -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node]) +export const node = makeLocationNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node], +}) + +export const refreshNode = makeLocationNode({ + name: "project-copy-refresh", + layer: Layer.effectDiscard(refreshAfterBoot), + deps: [node, Location.node], +}) diff --git a/packages/core/src/project/directories.ts b/packages/core/src/project/directories.ts index 7c0522107a6..6c9ad2e5159 100644 --- a/packages/core/src/project/directories.ts +++ b/packages/core/src/project/directories.ts @@ -3,8 +3,8 @@ export * as ProjectDirectories from "./directories" import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" -import { LayerNode } from "../effect/layer-node" -import { AbsolutePath, optionalOmitUndefined } from "../schema" +import { makeGlobalNode } from "../effect/app-node" +import { AbsolutePath, optional } from "../schema" import { ProjectSchema } from "./schema" import { ProjectDirectoryTable } from "./sql" import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" @@ -39,7 +39,7 @@ export type ListInput = typeof ListInput.Type export const ListOutput = Schema.Array( Schema.Struct({ directory: AbsolutePath, - strategy: optionalOmitUndefined(Schema.String), + strategy: optional(Schema.String), }), ).annotate({ identifier: "Project.Directories" }) export type ListOutput = typeof ListOutput.Type @@ -57,7 +57,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ProjectDirectories") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const db = (yield* Database.Service).db @@ -155,5 +155,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make(layer, [Database.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 51d9581cc65..eed359abad2 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -1,14 +1,10 @@ export * as ProjectSchema from "./schema" import { Schema } from "effect" -import { AbsolutePath, withStatics } from "../schema" +import { Project } from "@opencode-ai/schema/project" +import { AbsolutePath } from "../schema" -export const ID = Schema.String.pipe( - Schema.brand("Project.ID"), - withStatics((schema) => ({ - global: schema.make("global"), - })), -) +export const ID = Project.ID export type ID = typeof ID.Type export const Vcs = Schema.Union([ diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 0d6084257b5..bbc481c97b9 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,69 +1,26 @@ export * as ProviderV2 from "./provider" -import { withStatics } from "./schema" -import { Schema } from "effect" +import { Types } from "effect" +import { Provider } from "@opencode-ai/schema/provider" -export const ID = Schema.String.pipe( - Schema.brand("ProviderV2.ID"), - withStatics((schema) => ({ - // Well-known providers - kilo: schema.make("kilo"), // kilocode_change - Kilo well-known provider id - opencode: schema.make("opencode"), - anthropic: schema.make("anthropic"), - openai: schema.make("openai"), - google: schema.make("google"), - googleVertex: schema.make("google-vertex"), - githubCopilot: schema.make("github-copilot"), - amazonBedrock: schema.make("amazon-bedrock"), - azure: schema.make("azure"), - openrouter: schema.make("openrouter"), - mistral: schema.make("mistral"), - gitlab: schema.make("gitlab"), - })), -) +// kilocode_change - preserve Kilo's well-known routing ID without forking the shared schema +export const ID = Object.assign(Provider.ID, { kilo: Provider.ID.make("kilo") }) export type ID = typeof ID.Type -export const AISDK = Schema.Struct({ - type: Schema.Literal("aisdk"), - package: Schema.String, - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) +export const AISDK = Provider.AISDK -export const Native = Schema.Struct({ - type: Schema.Literal("native"), - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown), -}) +export const Native = Provider.Native -export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) -export type Api = typeof Api.Type +export const Api = Provider.Api +export type Api = Provider.Api +export type MutableApi = T extends Api + ? Omit, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any }) + : never -export const Request = Schema.Struct({ - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.Record(Schema.String, Schema.Any), -}) -export type Request = typeof Request.Type +export const Request = Provider.Request +export type Request = Provider.Request -export class Info extends Schema.Class("ProviderV2.Info")({ - id: ID, - name: Schema.String, - disabled: Schema.Boolean.pipe(Schema.optional), - api: Api, - request: Request, -}) { - static empty(providerID: ID): Info { - return new Info({ - id: providerID, - name: providerID, - api: { - type: "native", - settings: {}, - }, - request: { - headers: {}, - body: {}, - }, - }) - } -} +export const Info = Provider.Info +export type Info = Provider.Info + +export type MutableInfo = Omit, "api"> & { api: MutableApi } diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 0157c0d8d1d..44d95cb5085 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -1,11 +1,12 @@ export * as Pty from "./pty" +import { makeLocationNode } from "./effect/app-node" import type { Disp, Proc } from "#pty" import { Context, Effect, Layer, Schema, Types } from "effect" +import { Pty } from "@opencode-ai/schema/pty" import { Config } from "./config" import { EventV2 } from "./event" import { Location } from "./location" -import { NonNegativeInt, PositiveInt } from "./schema" import { PtyID } from "./pty/schema" import { SessionSchema } from "./session/schema" // kilocode_change import { Shell } from "./shell" @@ -39,45 +40,26 @@ type Active = { stopping: boolean // kilocode_change } -export const Info = Schema.Struct({ - id: PtyID, - title: Schema.String, - command: Schema.String, - args: Schema.Array(Schema.String), - cwd: Schema.String, - status: Schema.Literals(["running", "exited"]), - // Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time. - pid: NonNegativeInt, - // Present once status is "exited". - exitCode: Schema.optional(NonNegativeInt), - sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change -}).annotate({ identifier: "Pty" }) - +// kilocode_change - the Kilo `sessionID` field now lives on the canonical shared schema (see +// packages/schema/src/pty.ts) so the generated SDK carries it; reuse that schema verbatim here. +export const Info = Pty.Info export type Info = Types.DeepMutable -export const CreateInput = Schema.Struct({ - command: Schema.optional(Schema.String), - args: Schema.optional(Schema.Array(Schema.String)), - cwd: Schema.optional(Schema.String), - title: Schema.optional(Schema.String), - env: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) +export const CreateInput = Pty.CreateInput export type CreateInput = Types.DeepMutable export const UpdateInput = Schema.Struct({ - title: Schema.optional(Schema.String), + ...Pty.UpdateInput.fields, sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change - size: Schema.optional( - Schema.Struct({ - rows: PositiveInt, - cols: PositiveInt, - }), - ), }) export type UpdateInput = Types.DeepMutable +// kilocode_change - the shared events already carry Kilo's extended Info (see packages/schema/src/pty.ts), +// so reuse them verbatim instead of redefining pty.created/pty.updated here. +export const Event = Pty.Event + export type AttachInput = { // Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer. readonly cursor?: number @@ -108,13 +90,6 @@ export class ExitedError extends Schema.TaggedErrorClass()("Pty.Exi ptyID: PtyID, }) {} -export const Event = { - Created: EventV2.define({ type: "pty.created", schema: { info: Info } }), - Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }), - Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }), - Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }), -} - export interface Interface { readonly list: () => Effect.Effect readonly get: (id: PtyID) => Effect.Effect @@ -127,7 +102,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Pty") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -378,3 +353,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] }) diff --git a/packages/core/src/pty/schema.ts b/packages/core/src/pty/schema.ts index b8c973862f9..ab0c40521b1 100644 --- a/packages/core/src/pty/schema.ts +++ b/packages/core/src/pty/schema.ts @@ -1,13 +1 @@ -import { Schema } from "effect" -import { Identifier } from "../id/id" -import { withStatics } from "../schema" - -const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) - -export type PtyID = typeof ptyIdSchema.Type - -export const PtyID = ptyIdSchema.pipe( - withStatics((schema: typeof ptyIdSchema) => ({ - ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)), - })), -) +export { ID as PtyID } from "@opencode-ai/schema/pty" diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index c625390be02..07838b14157 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -1,18 +1,15 @@ export * as PtyTicket from "./ticket" import { WorkspaceV2 } from "../workspace" -import { PositiveInt } from "../schema" +import { PtyTicket } from "@opencode-ai/schema/pty-ticket" import { PtyID } from "./schema" -import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" -import { LayerNode } from "../effect/layer-node" +import { Cache, Context, Duration, Effect, Layer } from "effect" +import { makeGlobalNode } from "../effect/app-node" const DEFAULT_TTL = Duration.seconds(60) const CAPACITY = 10_000 -export const ConnectToken = Schema.Struct({ - ticket: Schema.String, - expires_in: PositiveInt, -}) +export const ConnectToken = PtyTicket.ConnectToken export type Scope = { readonly ptyID: PtyID @@ -54,7 +51,6 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) => }) }) -export const layer = Layer.effect(Service, make()) +const layer = Layer.effect(Service, make()) -export const defaultLayer = layer -export const node = LayerNode.make(layer, []) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) diff --git a/packages/core/src/public-event-manifest.ts b/packages/core/src/public-event-manifest.ts new file mode 100644 index 00000000000..11b84f79054 --- /dev/null +++ b/packages/core/src/public-event-manifest.ts @@ -0,0 +1,7 @@ +export * as PublicEventManifest from "./public-event-manifest" + +import { Event } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" + +export const Definitions = EventManifest.ServerDefinitions +export const Latest = Event.latest(Definitions) diff --git a/packages/core/src/public/agent.ts b/packages/core/src/public/agent.ts deleted file mode 100644 index ade2096f899..00000000000 --- a/packages/core/src/public/agent.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Agent from "./agent" - -import { AgentV2 } from "../agent" - -export const ID = AgentV2.ID -export type ID = AgentV2.ID diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts deleted file mode 100644 index 2229039b9af..00000000000 --- a/packages/core/src/public/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Intentional supported native API. Other core subpaths remain internal implementation surfaces. */ -export { Agent } from "./agent" -export { Model } from "./model" -export { OpenCode } from "./opencode" -export { Session } from "./session" -export { Tool } from "./tool" -export { Location } from "./location" -export { Prompt } from "../session/prompt" -export { AbsolutePath } from "../schema" diff --git a/packages/core/src/public/location.ts b/packages/core/src/public/location.ts deleted file mode 100644 index aab15181d19..00000000000 --- a/packages/core/src/public/location.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Location from "./location" - -import { Location } from "../location" - -export const Ref = Location.Ref -export type Ref = Location.Ref diff --git a/packages/core/src/public/model.ts b/packages/core/src/public/model.ts deleted file mode 100644 index ab92b8dfe76..00000000000 --- a/packages/core/src/public/model.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * as Model from "./model" - -import { ModelV2 } from "../model" - -export const ID = ModelV2.ID -export type ID = ModelV2.ID - -export const Ref = ModelV2.Ref -export type Ref = ModelV2.Ref diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts deleted file mode 100644 index 7388705d8c4..00000000000 --- a/packages/core/src/public/opencode.ts +++ /dev/null @@ -1,129 +0,0 @@ -export * as OpenCode from "./opencode" - -import { Context, Effect, Layer } from "effect" -import { Catalog } from "../catalog" -import { Database } from "../database/database" -import { EventV2 } from "../event" -import { LocationServiceMap } from "../location-layer" -import { PluginBoot } from "../plugin/boot" -import { ProjectV2 } from "../project" -import { SessionV2 } from "../session" -import * as SessionExecutionLocal from "../session/execution/local" -import { SessionProjector } from "../session/projector" -import { SessionStore } from "../session/store" -import { ApplicationTools } from "../tool/application-tools" -import { Session } from "./session" -import { Tool } from "./tool" - -export interface Interface { - readonly sessions: Session.Interface - readonly tools: Tool.Interface -} - -/** Intentional public native API for Effect applications embedding OpenCode. */ -export class Service extends Context.Service()("@opencode/public/OpenCode") {} - -class SessionModelValidation extends Context.Service< - SessionModelValidation, - { - readonly validate: ( - input: Session.SwitchModelInput & { readonly location: Session.Info["location"] }, - ) => Effect.Effect - } ->()("@opencode/public/OpenCode/SessionModelValidation") {} - -const ApplicationToolsLayer = ApplicationTools.layer -const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer)) -const SessionModelValidationLayer = Layer.effect( - SessionModelValidation, - Effect.gen(function* () { - const locations = yield* LocationServiceMap - return SessionModelValidation.of({ - validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) { - yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() - const catalog = yield* Catalog.Service - const model = (yield* catalog.model.available()).find( - (model) => model.providerID === input.model.providerID && model.id === input.model.id, - ) - if (!model) - return yield* new Session.ModelUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - }) - if ( - input.model.variant !== undefined && - input.model.variant !== "default" && - !model.variants.some((variant) => variant.id === input.model.variant) - ) - return yield* new Session.VariantUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - variant: input.model.variant, - }) - }).pipe(Effect.provide(locations.get(input.location))) - }), - }) - }), -) - -const SessionsLayer = Layer.merge( - SessionV2.layer.pipe( - Layer.provide(SessionProjector.layer), - Layer.provide(SessionExecutionLocal.layer), - Layer.provide(SessionStore.layer), - Layer.provide(EventV2.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, - ), - SessionModelValidationLayer, -).pipe(Layer.provide(LocationServicesLayer)) -// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const sessions = yield* SessionV2.Service - const tools = yield* ApplicationTools.Service - const validation = yield* SessionModelValidation - return Service.of({ - tools: { register: tools.register }, - sessions: { - create: (input) => - sessions.create({ - id: input.id, - agent: input.agent, - model: input.model, - location: input.location, - }), - get: sessions.get, - list: sessions.list, - switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) { - const session = yield* sessions.get(input.sessionID) - yield* validation.validate({ ...input, location: session.location }) - yield* sessions.switchModel(input) - }), - interrupt: sessions.interrupt, - prompt: (input) => - sessions.prompt({ - id: input.id, - sessionID: input.sessionID, - prompt: input.prompt, - delivery: input.delivery, - }), - messages: (input) => - sessions.messages({ - sessionID: input.sessionID, - limit: input.limit, - order: input.order, - cursor: input.cursor, - }), - message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }), - context: sessions.context, - events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }), - }, - }) - }), -).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer))) - -// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts deleted file mode 100644 index 6c61aff3b6d..00000000000 --- a/packages/core/src/public/session.ts +++ /dev/null @@ -1,119 +0,0 @@ -export * as Session from "./session" - -import { Effect, Schema, Stream } from "effect" -import { EventV2 } from "../event" -import { ModelV2 } from "../model" -import { SessionV2 } from "../session" -import { MessageDecodeError } from "../session/error" -import { SessionEvent } from "../session/event" -import { SessionInput } from "../session/input" -import { SessionMessage } from "../session/message" -import { Prompt } from "../session/prompt" -import { Agent } from "./agent" -import { Location } from "./location" -import { Model } from "./model" - -export const ID = SessionV2.ID -export type ID = SessionV2.ID - -export const Info = SessionV2.Info -export type Info = SessionV2.Info - -export const MessageID = SessionMessage.ID -export type MessageID = SessionMessage.ID - -export const Message = SessionMessage.Message -export type Message = SessionMessage.Message - -export const Admission = SessionInput.Admitted -export type Admission = SessionInput.Admitted - -export const Delivery = SessionInput.Delivery -export type Delivery = SessionInput.Delivery - -export const ListInput = SessionV2.ListInput -export type ListInput = SessionV2.ListInput - -export const EventCursor = EventV2.Cursor -export type EventCursor = EventV2.Cursor -export type Event = EventV2.CursorEvent - -export const NotFoundError = SessionV2.NotFoundError -export type NotFoundError = SessionV2.NotFoundError - -export const PromptConflictError = SessionV2.PromptConflictError -export type PromptConflictError = SessionV2.PromptConflictError - -export class ModelUnavailableError extends Schema.TaggedErrorClass()( - "Session.ModelUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - }, -) {} - -export class VariantUnavailableError extends Schema.TaggedErrorClass()( - "Session.VariantUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - variant: ModelV2.VariantID, - }, -) {} - -export { MessageDecodeError } - -export interface CreateInput { - readonly id?: ID - readonly agent?: Agent.ID - readonly model?: Model.Ref - readonly location: Location.Ref -} - -export interface PromptInput { - readonly id?: MessageID - readonly sessionID: ID - readonly prompt: Prompt - readonly delivery?: Delivery -} - -export interface SwitchModelInput { - readonly sessionID: ID - readonly model: Model.Ref -} - -export interface MessagesInput { - readonly sessionID: ID - readonly limit?: number - readonly order?: "asc" | "desc" - readonly cursor?: { - readonly id: MessageID - readonly direction: "previous" | "next" - } -} - -export interface MessageInput { - readonly sessionID: ID - readonly messageID: MessageID -} - -export interface EventsInput { - readonly sessionID: ID - readonly after?: EventCursor -} - -export interface Interface { - readonly create: (input: CreateInput) => Effect.Effect - readonly get: (sessionID: ID) => Effect.Effect - readonly list: (input?: ListInput) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect - readonly switchModel: ( - input: SwitchModelInput, - ) => Effect.Effect - /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */ - readonly interrupt: (sessionID: ID) => Effect.Effect - readonly messages: (input: MessagesInput) => Effect.Effect - readonly message: (input: MessageInput) => Effect.Effect - readonly context: (sessionID: ID) => Effect.Effect - readonly events: (input: EventsInput) => Stream.Stream -} diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts deleted file mode 100644 index 97b436fed92..00000000000 --- a/packages/core/src/public/tool.ts +++ /dev/null @@ -1,17 +0,0 @@ -export * as Tool from "./tool" - -import { Effect, Scope } from "effect" -import type { AnyTool, RegistrationError } from "../tool/tool" - -export { Failure, RegistrationError, make } from "../tool/tool" -export type { AnyTool, Content, Context, Definition } from "../tool/tool" - -export interface Interface { - /** - * Register same-process tools on this OpenCode instance for the current Scope. - * Location tools with the same name take precedence where they are installed. - * Closing the Scope removes the tools immediately, so calls that have not - * started settling may fail because the tool is no longer available. - */ - readonly register: (tools: Readonly>) => Effect.Effect -} diff --git a/packages/core/src/question.ts b/packages/core/src/question.ts index a489fb9aac2..79e0ea5e038 100644 --- a/packages/core/src/question.ts +++ b/packages/core/src/question.ts @@ -1,83 +1,36 @@ export * as QuestionV2 from "./question" +import { makeLocationNode } from "./effect/app-node" import { Context, Deferred, Effect, Layer, Schema } from "effect" +import { Question } from "@opencode-ai/schema/question" import { EventV2 } from "./event" -import { Identifier } from "./id/id" -import { withStatics } from "./schema" import { SessionSchema } from "./session/schema" -export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( - Schema.brand("QuestionV2.ID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("question", id)) })), -) +export const ID = Question.ID export type ID = typeof ID.Type -export const Option = Schema.Struct({ - label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), - description: Schema.String.annotate({ description: "Explanation of choice" }), -}).annotate({ identifier: "QuestionV2.Option" }) +export const Option = Question.Option export type Option = typeof Option.Type -const base = { - question: Schema.String.annotate({ description: "Complete question" }), - header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), - options: Schema.Array(Option).annotate({ description: "Available choices" }), - multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }), -} - -export const Info = Schema.Struct({ - ...base, - custom: Schema.Boolean.pipe(Schema.optional).annotate({ - description: "Allow typing a custom answer (default: true)", - }), -}).annotate({ identifier: "QuestionV2.Info" }) +export const Info = Question.Info export type Info = typeof Info.Type -export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" }) +export const Prompt = Question.Prompt export type Prompt = typeof Prompt.Type -export const Tool = Schema.Struct({ - messageID: Schema.String, - callID: Schema.String, -}).annotate({ identifier: "QuestionV2.Tool" }) +export const Tool = Question.Tool export type Tool = typeof Tool.Type -export const Request = Schema.Struct({ - id: ID, - sessionID: SessionSchema.ID, - questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), - tool: Tool.pipe(Schema.optional), -}).annotate({ identifier: "QuestionV2.Request" }) +export const Request = Question.Request export type Request = typeof Request.Type -export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" }) +export const Answer = Question.Answer export type Answer = typeof Answer.Type -export const Reply = Schema.Struct({ - answers: Schema.Array(Answer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}).annotate({ identifier: "QuestionV2.Reply" }) +export const Reply = Question.Reply export type Reply = typeof Reply.Type -export const Event = { - Asked: EventV2.define({ type: "question.v2.asked", schema: Request.fields }), - Replied: EventV2.define({ - type: "question.v2.replied", - schema: { - sessionID: SessionSchema.ID, - requestID: ID, - answers: Schema.Array(Answer), - }, - }), - Rejected: EventV2.define({ - type: "question.v2.rejected", - schema: { - sessionID: SessionSchema.ID, - requestID: ID, - }, - }), -} +export const Event = Question.Event export class RejectedError extends Schema.TaggedErrorClass()("QuestionV2.RejectedError", {}) { override get message() { @@ -119,7 +72,7 @@ interface Pending { * layer once per embedded Location so replies cannot settle another Location's * deferred request. */ -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -196,3 +149,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index a7890243c50..9c1b9d0d7f6 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -1,7 +1,8 @@ export * as Reference from "./reference" -import { Context, Effect, Layer, Schema, Scope } from "effect" -import { castDraft } from "immer" +import { makeLocationNode } from "./effect/app-node" +import { Context, Effect, Layer, Scope, Types } from "effect" +import { Reference } from "@opencode-ai/schema/reference" import { Global } from "./global" import { EventV2 } from "./event" import { Repository } from "./repository" @@ -9,55 +10,38 @@ import { RepositoryCache } from "./repository-cache" import { AbsolutePath } from "./schema" import { State } from "./state" -export class LocalSource extends Schema.Class("Reference.LocalSource")({ - type: Schema.Literal("local"), - path: AbsolutePath, - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), -}) {} +export const LocalSource = Reference.LocalSource +export type LocalSource = Reference.LocalSource -export class GitSource extends Schema.Class("Reference.GitSource")({ - type: Schema.Literal("git"), - repository: Schema.String, - branch: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), -}) {} +export const GitSource = Reference.GitSource +export type GitSource = Reference.GitSource -export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type")) -export type Source = typeof Source.Type +export const Source = Reference.Source +export type Source = Reference.Source -export const Event = { - Updated: EventV2.define({ type: "reference.updated", schema: {} }), -} +export const Event = Reference.Event -export class Info extends Schema.Class("Reference.Info")({ - name: Schema.String, - path: AbsolutePath, - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), - source: Source, -}) {} +export const Info = Reference.Info +export type Info = Reference.Info type Data = { - sources: Map + sources: Map> } -type Editor = { +type Draft = { add(name: string, source: Source): void remove(name: string): void list(): readonly [string, Source][] } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly replace: (sources: readonly (readonly [string, Source])[]) => Effect.Effect // kilocode_change readonly list: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Reference") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const global = yield* Global.Service @@ -65,26 +49,26 @@ export const layer = Layer.effect( const cache = yield* RepositoryCache.Service const scope = yield* Scope.Scope const materialized = new Map() - const state = State.create({ + const state = State.create({ initial: () => ({ sources: new Map() }), - editor: (draft) => ({ - add: (name, source) => draft.sources.set(name, castDraft(source)), + draft: (draft) => ({ + add: (name, source) => draft.sources.set(name, source as Types.DeepMutable), remove: (name) => draft.sources.delete(name), list: () => Array.from(draft.sources.entries()) as [string, Source][], }), - finalize: (editor) => + finalize: (draft) => Effect.gen(function* () { materialized.clear() const seen = new Map() - for (const [name, source] of editor.list()) { + for (const [name, source] of draft.list()) { if (source.type === "local") { materialized.set( name, new Info({ name, path: source.path, - description: source.description, - hidden: source.hidden, + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.hidden === undefined ? {} : { hidden: source.hidden }), source, }), ) @@ -107,8 +91,8 @@ export const layer = Layer.effect( new Info({ name, path: AbsolutePath.make(target), - description: source.description, - hidden: source.hidden, + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.hidden === undefined ? {} : { hidden: source.hidden }), source, }), ) @@ -138,11 +122,18 @@ export const layer = Layer.effect( }), ), // kilocode_change end + reload: state.reload, list: Effect.fn("Reference.list")(function* () { - return Array.from(materialized.values()) - }), + return Array.from(materialized.values()) + }), }) }), ) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Global.node, EventV2.node, RepositoryCache.node], +}) diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index f567264768a..25566e2f2f6 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -1,7 +1,7 @@ export * as ReferenceGuidance from "./guidance" +import { makeLocationNode } from "../effect/app-node" import { Context, Effect, Layer, Schema } from "effect" -import { PluginBoot } from "../plugin/boot" import { Reference } from "../reference" import { SystemContext } from "../system-context/index" @@ -31,15 +31,13 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/ReferenceGuidance") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const boot = yield* PluginBoot.Service const references = yield* Reference.Service return Service.of({ load: Effect.fn("ReferenceGuidance.load")(function* () { - yield* boot.wait() const available = (yield* references.list()) .filter((reference) => reference.description !== undefined) .map((reference) => ({ @@ -67,3 +65,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] }) diff --git a/packages/core/src/repository-cache.ts b/packages/core/src/repository-cache.ts index 894dc38faa6..988236743b3 100644 --- a/packages/core/src/repository-cache.ts +++ b/packages/core/src/repository-cache.ts @@ -4,6 +4,8 @@ import { FSUtil } from "./fs-util" import { Git } from "./git" import { Global } from "./global" import { Repository } from "./repository" +import { AbsolutePath } from "./schema" +import { makeGlobalNode } from "./effect/app-node" import { EffectFlock } from "./util/effect-flock" export type Result = { @@ -119,7 +121,7 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi }) }) -export const layer: Layer.Layer = +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { @@ -142,15 +144,15 @@ export const layer: Layer.Layer new CloneFailedError({ repository, message: errorMessage(error) })), - ) - if (result.exitCode !== 0) { - return yield* new CloneFailedError({ - repository, - message: resultMessage(result, `Failed to clone ${repository}`), + yield* git.repo + .clone({ + remote: input.reference.remote, + directory: AbsolutePath.make(localPath), + branch: input.branch, }) - } + .pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message }))) } if (status === "refreshed") { - const fetch = yield* git - .fetch(localPath) - .pipe( - Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })), - ) - if (fetch.exitCode !== 0) { - return yield* new FetchFailedError({ - repository, - message: resultMessage(fetch, `Failed to refresh ${repository}`), - }) - } + if (!existing) + return yield* new FetchFailedError({ repository, message: "Repository is unavailable" }) + yield* git.sync + .fetchRemotes(existing) + .pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message }))) if (input.branch) { const requestedBranch = input.branch - const fetchBranch = yield* git - .fetchBranch(localPath, requestedBranch) - .pipe( - Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })), - ) - if (fetchBranch.exitCode !== 0) { - return yield* new FetchFailedError({ - repository, - message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`), - }) - } + yield* git.sync + .fetchBranch(existing, { branch: requestedBranch }) + .pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message }))) - const checkout = yield* git.checkout(localPath, requestedBranch).pipe( + yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe( Effect.mapError( (error) => new CheckoutFailedError({ repository, branch: requestedBranch, - message: errorMessage(error), + message: error.message, }), ), ) - if (checkout.exitCode !== 0) { - return yield* new CheckoutFailedError({ - repository, - branch: requestedBranch, - message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`), - }) - } } - const reset = yield* git - .reset(localPath, yield* resetTarget(git, localPath, input.branch)) - .pipe( - Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })), - ) - if (reset.exitCode !== 0) { - return yield* new ResetFailedError({ - repository, - message: resultMessage(reset, `Failed to reset ${repository}`), - }) - } + yield* git.sync + .resetHard(existing, yield* resetTarget(git, existing, input.branch)) + .pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message }))) } + const checkout = yield* git.repo.discover(AbsolutePath.make(localPath)) + return { repository, host: input.reference.host, remote: input.reference.remote, localPath, status, - head: yield* git.head(localPath), - branch: yield* git.branch(localPath), + head: checkout ? yield* git.history.head(checkout) : undefined, + branch: checkout ? yield* git.history.branch(checkout) : undefined, } satisfies Result }), `repository-cache:${localPath}`, @@ -252,12 +223,11 @@ export const layer: Layer.Layer = layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Global.defaultLayer), -) +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node], +}) function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { if (!input.reuse) return "cloned" as const @@ -275,17 +245,17 @@ function cacheOperation(effect: Effect.Effect, operation: stri ) } -const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) { +const resetTarget = Effect.fnUntraced(function* ( + git: Git.Interface, + repository: Git.Repository, + requestedBranch?: string, +) { if (requestedBranch) return `origin/${requestedBranch}` - const remoteHead = yield* git.remoteHead(cwd) - if (remoteHead) return remoteHead - const currentBranch = yield* git.branch(cwd) + const remoteHead = yield* git.history.defaultRemoteBranch(repository) + if (remoteHead) return `origin/${remoteHead}` + const currentBranch = yield* git.history.branch(repository) if (currentBranch) return `origin/${currentBranch}` return "HEAD" }) -function resultMessage(result: Git.Result, fallback: string) { - return result.stderr.trim() || result.text.trim() || fallback -} - export * as RepositoryCache from "./repository-cache" diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index e1545195606..5361c083a43 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -2,10 +2,8 @@ export * as Ripgrep from "./ripgrep" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" -import path from "path" -import { LayerNode } from "./effect/layer-node" -import { Entry, Match } from "./filesystem/schema" -import { FSUtil } from "./fs-util" +import { makeGlobalNode } from "./effect/app-node" +import { Entry, Match } from "@opencode-ai/schema/filesystem" import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" @@ -43,7 +41,7 @@ type RawMatchData = (typeof RawMatch.Type)["data"] export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class InvalidPatternError extends Schema.TaggedErrorClass()("Ripgrep.InvalidPatternError", { @@ -102,7 +100,7 @@ const failure = (message: string, cause?: unknown) => new Error({ message, cause const isInvalidPattern = (stderr: string) => stderr.includes("regex parse error") || stderr.includes("error parsing regex") -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const process = yield* AppProcess.Service @@ -202,14 +200,12 @@ export const layer = Layer.effect( // kilocode_change start - retain spawn metadata after mapping paths Effect.map((result) => ({ ...result, - items: result.items.map((relative) => { - const absolute = path.resolve(input.cwd, relative) - return new Entry({ + items: result.items.map((relative) => + Entry.make({ path: RelativePath.make(relative), type: "file", - mime: FSUtil.mimeType(absolute), - }) - }), + }), + ), })), // kilocode_change end Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), @@ -234,10 +230,9 @@ export const layer = Layer.effect( .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") return Effect.succeed( - new Entry({ + Entry.make({ path: RelativePath.make(relative), type: "file", - mime: FSUtil.mimeType(path.resolve(input.cwd, relative)), }), ) }, @@ -290,12 +285,10 @@ export const layer = Layer.effect( .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") - const absolute = path.resolve(input.cwd, relative) - return new Match({ - entry: new Entry({ + return Match.make({ + entry: Entry.make({ path: RelativePath.make(relative), type: "file", - mime: FSUtil.mimeType(absolute), }), line: match.line_number, offset: match.absolute_offset, @@ -314,5 +307,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer))) -export const node = LayerNode.make(layer, [RipgrepBinary.node, AppProcess.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) diff --git a/packages/core/src/ripgrep/binary.ts b/packages/core/src/ripgrep/binary.ts index 4d34a7d4bd7..e1c7ec664b0 100644 --- a/packages/core/src/ripgrep/binary.ts +++ b/packages/core/src/ripgrep/binary.ts @@ -4,8 +4,8 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "../cross-spawn-spawner" -import { LayerNode } from "../effect/layer-node" -import { httpClient } from "../effect/layer-node-platform" +import { makeGlobalNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" import { FSUtil } from "../fs-util" import { Global } from "../global" import { which } from "../util/which" @@ -28,7 +28,7 @@ export namespace RipgrepBinary { export class Service extends Context.Service()("@opencode/RipgrepBinary") {} - export const layer = Layer.effect( + const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -125,11 +125,9 @@ export namespace RipgrepBinary { }), ) - export const defaultLayer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - ) - - export const node = LayerNode.make(layer, [FSUtil.node, httpClient, CrossSpawnSpawner.node]) + export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node], + }) } diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 97b24dbda85..345cc222450 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,47 +1,20 @@ -import { Option, Schema, SchemaGetter } from "effect" -import { Hash } from "./util/hash" +import { Schema } from "effect" +import { + AbsolutePath, + DateTimeUtcFromMillis, + NonNegativeInt, + optional, + PositiveInt, + RelativePath, + statics, +} from "@opencode-ai/schema/schema" -export type ExternalID = { - readonly namespace: string - readonly key: string -} +export { AbsolutePath, DateTimeUtcFromMillis, NonNegativeInt, optional, PositiveInt, RelativePath, statics } -export const externalID = (prefix: string, input: ExternalID) => - `${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}` - -/** - * Integer greater than zero. - */ -export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) - -/** - * Integer greater than or equal to zero. - */ -export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) - -/** - * Relative file path (e.g., `src/components/Button.tsx`). - */ -export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) -export type RelativePath = Schema.Schema.Type - -/** - * Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`). - */ -export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) -export type AbsolutePath = Schema.Schema.Type - -/** - * Optional public JSON field that can hold explicit `undefined` on the type - * side but encodes it as an omitted key, matching legacy `JSON.stringify`. - */ -export const optionalOmitUndefined = (schema: S) => - Schema.optionalKey(schema).pipe( - Schema.decodeTo(Schema.optional(schema), { - decode: SchemaGetter.passthrough({ strict: false }), - encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), - }), - ) +// kilocode_change start - compatibility aliases for Kilo-owned schemas +export const optionalOmitUndefined = optional +export const withStatics = statics +// kilocode_change end /** * Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable` @@ -71,22 +44,6 @@ export type DeepMutable = T extends string | number | boolean | bigint | symb ? { -readonly [K in keyof T]: DeepMutable } : T -/** - * Attach static methods to a schema object. Designed to be used with `.pipe()`: - * - * @example - * export const Foo = fooSchema.pipe( - * withStatics((schema) => ({ - * zero: schema.make(0), - * from: Schema.decodeUnknownOption(schema), - * })) - * ) - */ -export const withStatics = - >(methods: (schema: S) => M) => - (schema: S): S & M => - Object.assign(schema, methods(schema)) - /** * Nominal wrapper for scalar types. The class itself is a valid schema — * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e629108994d..cb8bd1b4ce6 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,8 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect" +import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect" +import { ListAnchor } from "@opencode-ai/schema/session" import { and, asc, desc, eq, gt, isNotNull, like, lt, or, type SQL } from "drizzle-orm" // kilocode_change import { ProjectV2 } from "./project" import { WorkspaceV2 } from "./workspace" @@ -9,6 +10,7 @@ import { ModelV2 } from "./model" import { Location } from "./location" import { SessionMessage } from "./session/message" import { Prompt } from "./session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { EventV2 } from "./event" import { Database } from "./database/database" import { SessionProjector } from "./session/projector" @@ -25,11 +27,20 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" -import { logFailure } from "./session/logging" +import { makeGlobalNode } from "./effect/app-node" +import { LocationServiceMap } from "./location-service-map" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" import { normalize } from "./kilocode/session-message" // kilocode_change +import { Snapshot } from "./snapshot" +import { SessionRevert } from "./session/revert" +import { Revert } from "@opencode-ai/schema/revert" +import { FSUtil } from "./fs-util" +import { SessionDurable, type SessionDurableEvent } from "@opencode-ai/schema/durable-event-manifest" // kilocode_change + +export const RevertState = Revert.State +export type RevertState = Revert.State // get project -> project.locations // @@ -40,12 +51,7 @@ import { normalize } from "./kilocode/session-message" // kilocode_change // - by subpath // - by workspace (home is special) -export const ListAnchor = Schema.Struct({ - id: SessionSchema.ID, - time: Schema.Finite, - direction: Schema.Literals(["previous", "next"]), -}) -export type ListAnchor = typeof ListAnchor.Type +export { ListAnchor } const ListInputBase = { workspaceID: WorkspaceV2.ID.pipe(Schema.optional), @@ -100,6 +106,8 @@ export class PromptConflictError extends Schema.TaggedErrorClass Effect.Effect readonly events: (input: { sessionID: SessionSchema.ID - after?: EventV2.Cursor - }) => Stream.Stream, NotFoundError> - readonly switchAgent: (input: { + after?: number + }) => Stream.Stream // kilocode_change - released durable event compatibility + readonly history: (input: { sessionID: SessionSchema.ID - agent: string - }) => Effect.Effect + after?: number + limit: number + }) => Effect.Effect<{ events: ReadonlyArray; hasMore: boolean }, NotFoundError> // kilocode_change + readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref @@ -138,7 +148,7 @@ export interface Interface { readonly prompt: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID - prompt: Prompt + prompt: PromptInput.Prompt delivery?: SessionInput.Delivery resume?: boolean }) => Effect.Effect @@ -156,36 +166,34 @@ export interface Interface { }) => Effect.Effect readonly compact: (input: CompactInput) => Effect.Effect readonly wait: (id: SessionSchema.ID) => Effect.Effect + readonly active: Effect.Effect> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect + readonly revert: { + readonly stage: (input: { + sessionID: SessionSchema.ID + messageID: SessionMessage.ID + files?: boolean + }) => Effect.Effect + readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect + readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect + } } export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const db = (yield* Database.Service).db + const database = yield* Database.Service + const db = database.db const events = yield* EventV2.Service const projects = yield* ProjectV2.Service const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service + const locations = yield* LocationServiceMap.Service const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) - const isDurableSessionEvent = Schema.is(SessionEvent.Durable) - const scope = yield* Effect.scope - - const enqueueWake = (admitted: SessionInput.Admitted) => - execution.wake(admitted.sessionID, admitted.admittedSeq).pipe( - Effect.tapCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : logFailure("Failed to wake Session", admitted.sessionID, cause), - ), - Effect.ignore, - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ) - + const isDurableSessionEvent = Schema.is(SessionDurable.schema) // kilocode_change - include released storage keys const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage(normalize({ ...row.data, id: row.id, type: row.type })).pipe( // kilocode_change - normalize released tool content on paginated reads @@ -316,11 +324,12 @@ export const layer = Layer.effect( : undefined const seq = anchor?.seq if (input.cursor && seq == null) return [] - const boundary = seq != null - ? order === "asc" - ? gt(SessionMessageTable.seq, seq) - : lt(SessionMessageTable.seq, seq) - : undefined + const boundary = + seq != null + ? order === "asc" + ? gt(SessionMessageTable.seq, seq) + : lt(SessionMessageTable.seq, seq) + : undefined const where = boundary ? and(eq(SessionMessageTable.session_id, input.sessionID), isNotNull(SessionMessageTable.seq), boundary) : and(eq(SessionMessageTable.session_id, input.sessionID), isNotNull(SessionMessageTable.seq)) // kilocode_change @@ -346,27 +355,28 @@ export const layer = Layer.effect( Stream.unwrap( result .get(input.sessionID) - .pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))), - ).pipe( - Stream.filter((event): event is EventV2.CursorEvent => - isDurableSessionEvent(event.event), - ), - ), + .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), + ).pipe(Stream.filter((event): event is SessionDurableEvent => isDurableSessionEvent(event))), // kilocode_change + history: Effect.fn("V2Session.history")(function* (input) { + yield* result.get(input.sessionID) + return yield* EventV2.readAggregate(db, { + ...input, + aggregateID: input.sessionID, + manifest: SessionDurable, + }) + }), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( Effect.gen(function* () { yield* result.get(input.sessionID) - const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) { - if (input.resume !== false) yield* enqueueWake(admitted) - return admitted - }, Effect.uninterruptible) + const prompt = resolvePrompt(input.prompt) const messageID = input.id ?? SessionMessage.ID.create() const delivery = input.delivery ?? "steer" - const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } + const expected = { sessionID: input.sessionID, messageID, prompt, delivery } const admitted = yield* SessionInput.admit(db, events, { id: messageID, sessionID: input.sessionID, - prompt: input.prompt, + prompt, delivery, }).pipe( Effect.catchDefect((defect) => @@ -377,7 +387,8 @@ export const layer = Layer.effect( ) if (!SessionInput.equivalent(admitted, expected)) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - return yield* returnPrompt(admitted) + if (input.resume !== false) yield* execution.wake(admitted.sessionID) + return admitted }), ), ), @@ -387,11 +398,23 @@ export const layer = Layer.effect( skill: Effect.fn("V2Session.skill")(function* () { return yield* new OperationUnavailableError({ operation: "skill" }) }), - switchAgent: Effect.fn("V2Session.switchAgent")(function* () { - return yield* new OperationUnavailableError({ operation: "switchAgent" }) + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + agent: input.agent, + }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* result.get(input.sessionID) + const session = yield* result.get(input.sessionID) + if ( + session.model?.providerID === input.model.providerID && + session.model.id === input.model.id && + (session.model.variant ?? "default") === (input.model.variant ?? "default") + ) + return yield* events.publish(SessionEvent.ModelSwitched, { sessionID: input.sessionID, messageID: SessionMessage.ID.create(), @@ -407,38 +430,65 @@ export const layer = Layer.effect( yield* result.get(sessionID) return yield* new OperationUnavailableError({ operation: "wait" }) }), + active: execution.active, resume: Effect.fn("V2Session.resume")(function* (sessionID) { yield* result.get(sessionID) yield* execution.resume(sessionID) }), interrupt: Effect.fn("V2Session.interrupt")((sessionID) => - Effect.uninterruptible( - Effect.gen(function* () { - const session = yield* store.get(sessionID) - if (!session) return yield* execution.interrupt(sessionID) - // kilocode_change start - keep interrupt operational while preserving released durable event compatibility. - const seq = yield* SessionInput.latestSeq(db, sessionID) - yield* events.publish(SessionEvent.InterruptRequested, { - sessionID, - timestamp: yield* DateTime.now, - }) - yield* execution.interrupt(sessionID, seq) - // kilocode_change end - }), - ), + Effect.uninterruptible(execution.interrupt(sessionID)), ), + revert: { + stage: Effect.fn("V2Session.revert.stage")(function* (input) { + const session = yield* result.get(input.sessionID) + return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( + Effect.provideService(Database.Service, database), + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.clear(session).pipe( + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) + }), + }, }) return result }), ) -export const defaultLayer = layer.pipe( - Layer.provide(SessionExecution.noopLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(SessionProjector.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, -) +const resolvePrompt = (input: PromptInput.Prompt) => + Prompt.make({ + text: input.text, + agents: input.agents, + files: input.files?.map((file) => { + const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1] + const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri) + return { + ...file, + mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)), + } + }), + }) + +export const node = makeGlobalNode({ + service: Service, + layer: layer.pipe(Layer.orDie), + deps: [ + Database.node, + EventV2.node, + ProjectV2.node, + SessionExecution.node, + SessionStore.node, + LocationServiceMap.node, + SessionProjector.node, + ], +}) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 1fb8df92e6e..979d9800373 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -1,54 +1,35 @@ export * as SessionContextEpoch from "./context-epoch" -import { and, eq, isNull, lt, or, sql } from "drizzle-orm" +import { and, eq, isNull } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" -import { AgentV2 } from "../agent" import type { Database } from "../database/database" import { EventV2 } from "../event" -import { Location } from "../location" import { SystemContext } from "../system-context/index" import { ContextSnapshotDecodeError } from "./error" import { SessionEvent } from "./event" +import { SessionHistory } from "./history" import { SessionInput } from "./input" -import { SessionMessageID } from "./message-id" +import { SessionMessage } from "./message" +import { Location } from "../location" // kilocode_change import { SessionSchema } from "./schema" import { SessionContextEpochTable, SessionTable } from "./sql" type DatabaseService = Database.Interface["db"] -class RevisionMismatch extends Error {} -class LocationMismatch extends Error {} -export class AgentMismatch extends Error {} -export class AgentReplacementBlocked extends Schema.TaggedErrorClass()( - "SessionContextEpoch.AgentReplacementBlocked", - { sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID }, -) {} - -const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect.Effect => - attempt().pipe( - Effect.catchDefect((defect) => - defect instanceof RevisionMismatch - ? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt))) - : Effect.die(defect), - ), - ) +class LocationMismatch extends Error {} // kilocode_change - a concurrent Session move must abort the epoch interface Prepared { readonly baseline: string readonly baselineSeq: number - readonly revision: number } export function initialize( db: DatabaseService, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, + location: Location.Ref, // kilocode_change ): Effect.Effect { - return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe( - Effect.withSpan("SessionContextEpoch.initialize"), - ) + return initializeOnce(db, context, sessionID, location).pipe(Effect.withSpan("SessionContextEpoch.initialize")) } export function prepare( @@ -56,12 +37,9 @@ export function prepare( events: EventV2.Interface, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, -): Effect.Effect { - return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe( - Effect.withSpan("SessionContextEpoch.prepare"), - ) + location: Location.Ref, // kilocode_change +): Effect.Effect { + return prepareOnce(db, events, context, sessionID, location).pipe(Effect.withSpan("SessionContextEpoch.prepare")) } const prepareOnce = Effect.fnUntraced(function* ( @@ -69,57 +47,52 @@ const prepareOnce = Effect.fnUntraced(function* ( events: EventV2.Interface, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, + location: Location.Ref, // kilocode_change ) { - const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" }) + const [value, stored, compaction] = yield* Effect.all( + [context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)], + { concurrency: "unbounded" }, + ) if (!stored) { const generation = yield* SystemContext.initialize(value) - const baselineSeq = yield* insert(db, sessionID, location, agent, generation) - return { baseline: generation.baseline, baselineSeq, revision: 0 } + const baselineSeq = yield* insert(db, sessionID, generation, location) // kilocode_change + return { baseline: generation.baseline, baselineSeq } } const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })), ) - const replacingAgent = stored.agent !== agent - const result = - stored.replacement_seq === null && !replacingAgent - ? yield* SystemContext.reconcile(value, snapshot) - : yield* SystemContext.replace(value, snapshot) - if (result._tag === "ReplacementBlocked" && replacingAgent) { - yield* fence(db, sessionID, agent, stored.revision) - return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent }) - } + const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined + const result = replacementSeq + ? yield* SystemContext.replace(value, snapshot) + : yield* SystemContext.reconcile(value, snapshot) if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") { - yield* fence(db, sessionID, agent, stored.revision) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision } + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } } if (result._tag === "ReplacementReady") { - const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID)) - yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation) - return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 } + const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID)) + yield* replace(db, sessionID, baselineSeq, result.generation) + return { baseline: result.generation.baseline, baselineSeq } } yield* events.publish( SessionEvent.ContextUpdated, - { sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text }, - { commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) }, + { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text }, + { commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) }, ) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 } + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } }) const initializeOnce = Effect.fnUntraced(function* ( db: DatabaseService, context: Effect.Effect, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, + location: Location.Ref, // kilocode_change ) { if (yield* exists(db, sessionID)) return const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize)) - const baselineSeq = yield* insert(db, sessionID, location, agent, generation) - return { baseline: generation.baseline, baselineSeq, revision: 0 } + const baselineSeq = yield* insert(db, sessionID, generation, location) // kilocode_change + return { baseline: generation.baseline, baselineSeq } }) const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { @@ -142,39 +115,6 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic .pipe(Effect.orDie) }) -const requireAgentSelection = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - agent: AgentV2.ID, -) { - const selected = yield* db - .select({ agent: SessionTable.agent }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch()) -}) - -export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - seq: number, -) { - return yield* db - .update(SessionContextEpochTable) - .set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - lt(SessionContextEpochTable.baseline_seq, seq), - or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)), - ), - ) - .run() - .pipe(Effect.orDie) -}) - export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, @@ -189,155 +129,72 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( const insert = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - location: Location.Ref, - agent: AgentV2.ID, generation: SystemContext.Generation, + location: Location.Ref, // kilocode_change ) { - return yield* db - .transaction( - () => - Effect.gen(function* () { - const placed = yield* db - .select({ agent: SessionTable.agent }) - .from(SessionTable) - .where( - and( - eq(SessionTable.id, sessionID), - eq(SessionTable.directory, location.directory), - location.workspaceID === undefined - ? isNull(SessionTable.workspace_id) - : eq(SessionTable.workspace_id, location.workspaceID), - ), - ) - .get() - .pipe(Effect.orDie) - if (!placed) return yield* Effect.die(new LocationMismatch()) - if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch()) - const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) - yield* db - .insert(SessionContextEpochTable) - .values({ - session_id: sessionID, - baseline: generation.baseline, - agent, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - revision: 0, - }) - .onConflictDoNothing() - .returning({ sessionID: SessionContextEpochTable.session_id }) - .get() - .pipe( - Effect.orDie, - Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))), - ) - return baselineSeq - }), - { behavior: "immediate" }, + // kilocode_change start - upstream only checks the Location before loading system context, so a move + // that lands during the load would still write an epoch for the source Location. Re-check here. + const placed = yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where( + and( + eq(SessionTable.id, sessionID), + eq(SessionTable.directory, location.directory), + location.workspaceID === undefined + ? isNull(SessionTable.workspace_id) + : eq(SessionTable.workspace_id, location.workspaceID), + ), ) + .get() .pipe(Effect.orDie) + if (!placed) return yield* Effect.die(new LocationMismatch()) + // kilocode_change end + const baselineSeq = yield* EventV2.latestSequence(db, sessionID) + yield* db + .insert(SessionContextEpochTable) + .values({ + session_id: sessionID, + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, + }) + .run() + .pipe(Effect.orDie) + return baselineSeq }) const replace = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - agent: AgentV2.ID, - expectedRevision: number, baselineSeq: number, generation: SystemContext.Generation, ) { - yield* db - .transaction( - () => - Effect.gen(function* () { - yield* requireAgentSelection(db, sessionID, agent) - const updated = yield* db - .update(SessionContextEpochTable) - .set({ - baseline: generation.baseline, - agent, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - replacement_seq: null, - revision: expectedRevision + 1, - }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - eq(SessionContextEpochTable.revision, expectedRevision), - ), - ) - .returning({ revision: SessionContextEpochTable.revision }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new RevisionMismatch()) - }), - { behavior: "immediate" }, - ) - .pipe(Effect.orDie) -}) - -const fence = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - agent: AgentV2.ID, - expectedRevision: number, -) { - const current = yield* db - .select({ selected: SessionTable.agent, revision: SessionContextEpochTable.revision }) - .from(SessionContextEpochTable) - .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!current || (current.selected !== null && current.selected !== agent)) - return yield* Effect.die(new AgentMismatch()) - if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch()) -}) - -export const current = Effect.fn("SessionContextEpoch.current")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - agent: AgentV2.ID, - revision: number, -) { - const value = yield* db - .select({ - agent: SessionContextEpochTable.agent, - selected: SessionTable.agent, - revision: SessionContextEpochTable.revision, + const updated = yield* db + .update(SessionContextEpochTable) + .set({ + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, }) - .from(SessionContextEpochTable) - .innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id)) .where(eq(SessionContextEpochTable.session_id, sessionID)) + .returning({ sessionID: SessionContextEpochTable.session_id }) .get() .pipe(Effect.orDie) - return ( - value !== undefined && - value.agent === agent && - (value.selected === null || value.selected === agent) && - value.revision === revision - ) + if (!updated) return yield* Effect.die("Context Epoch not found") }) const advance = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - expectedRevision: number, snapshot: SystemContext.Snapshot, ) { const updated = yield* db .update(SessionContextEpochTable) - .set({ snapshot, revision: expectedRevision + 1 }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - eq(SessionContextEpochTable.revision, expectedRevision), - isNull(SessionContextEpochTable.replacement_seq), - ), - ) - .returning({ revision: SessionContextEpochTable.revision }) + .set({ snapshot }) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .returning({ sessionID: SessionContextEpochTable.session_id }) .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new RevisionMismatch()) + if (!updated) return yield* Effect.die("Context Epoch not found") }) diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 16b784c30db..158e46dd073 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -5,7 +5,11 @@ import { SessionSchema } from "./schema" export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { sessionID: SessionSchema.ID, messageID: SessionMessage.ID, -}) {} +}) { + override get message() { + return `Failed to decode message ${this.messageID} in session ${this.sessionID}` + } +} export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass()( "Session.ContextSnapshotDecodeError", diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 8797ef036bc..78e40a69941 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -1,518 +1,2 @@ -import { Schema } from "effect" -import { ProviderMetadata, StoredToolContent, ToolContent } from "@opencode-ai/llm" // kilocode_change - durable events decode legacy content -import { EventV2 } from "../event" -import { ModelV2 } from "../model" -import { NonNegativeInt } from "../schema" -import { V2Schema } from "../v2-schema" -import { FileAttachment, Prompt } from "./prompt" -import { SessionSchema } from "./schema" -import { Location } from "../location" -import { RelativePath } from "../schema" -import { SessionMessageID } from "./message-id" - -export { FileAttachment } - -export const Source = Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - text: Schema.String, -}).annotate({ - identifier: "session.next.event.source", -}) -export type Source = typeof Source.Type - -const Base = { - timestamp: V2Schema.DateTimeUtcFromMillis, - sessionID: SessionSchema.ID, -} - -const options = { - sync: { - aggregate: "sessionID", - version: 1, - }, -} as const -const stepSettlementOptions = { - sync: { - aggregate: "sessionID", - version: 2, - }, -} as const - -export const UnknownError = Schema.Struct({ - type: Schema.Literal("unknown"), - message: Schema.String, -}).annotate({ - identifier: "Session.Error.Unknown", -}) -export type UnknownError = typeof UnknownError.Type - -export const AgentSwitched = EventV2.define({ - type: "session.next.agent.switched", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - agent: Schema.String, - }, -}) -export type AgentSwitched = typeof AgentSwitched.Type - -export const ModelSwitched = EventV2.define({ - type: "session.next.model.switched", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - model: ModelV2.Ref, - }, -}) -export type ModelSwitched = typeof ModelSwitched.Type - -export const Moved = EventV2.define({ - type: "session.next.moved", - ...options, - schema: { - ...Base, - location: Location.Ref, - subdirectory: RelativePath.pipe(Schema.optional), - }, -}) -export type Moved = typeof Moved.Type - -export const Prompted = EventV2.define({ - type: "session.next.prompted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - delivery: Schema.Literals(["steer", "queue"]), - }, -}) -export type Prompted = typeof Prompted.Type - -export namespace PromptLifecycle { - export const Admitted = EventV2.define({ - type: "session.next.prompt.admitted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - delivery: Schema.Literals(["steer", "queue"]), - }, - }) - export type Admitted = typeof Admitted.Type - - export const Promoted = EventV2.define({ - type: "session.next.prompt.promoted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - timeCreated: V2Schema.DateTimeUtcFromMillis, - }, - }) - export type Promoted = typeof Promoted.Type -} - -export const InterruptRequested = EventV2.define({ - type: "session.next.interrupt.requested", - schema: Base, -}) // kilocode_change - operational notification; released readers cannot decode a durable event with this type -export type InterruptRequested = typeof InterruptRequested.Type - -export const ContextUpdated = EventV2.define({ - type: "session.next.context.updated", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - text: Schema.String, - }, -}) -export type ContextUpdated = typeof ContextUpdated.Type - -export const Synthetic = EventV2.define({ - type: "session.next.synthetic", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - text: Schema.String, - }, -}) -export type Synthetic = typeof Synthetic.Type - -export namespace Shell { - export const Started = EventV2.define({ - type: "session.next.shell.started", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - callID: Schema.String, - command: Schema.String, - }, - }) - export type Started = typeof Started.Type - - export const Ended = EventV2.define({ - type: "session.next.shell.ended", - ...options, - schema: { - ...Base, - callID: Schema.String, - output: Schema.String, - }, - }) - export type Ended = typeof Ended.Type -} - -export namespace Step { - export const Started = EventV2.define({ - type: "session.next.step.started", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - agent: Schema.String, - model: ModelV2.Ref, - snapshot: Schema.String.pipe(Schema.optional), - }, - }) - export type Started = typeof Started.Type - - export const Ended = EventV2.define({ - type: "session.next.step.ended", - ...stepSettlementOptions, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - finish: Schema.String, - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - snapshot: Schema.String.pipe(Schema.optional), - }, - }) - export type Ended = typeof Ended.Type - - export const Failed = EventV2.define({ - type: "session.next.step.failed", - ...stepSettlementOptions, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - error: UnknownError, - }, - }) - export type Failed = typeof Failed.Type -} - -export namespace Text { - export const Started = EventV2.define({ - type: "session.next.text.started", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - textID: Schema.String, - }, - }) - export type Started = typeof Started.Type - - // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. - export const Delta = EventV2.define({ - type: "session.next.text.delta", - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - textID: Schema.String, - delta: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.text.ended", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - textID: Schema.String, - text: Schema.String, - }, - }) - export type Ended = typeof Ended.Type -} - -export namespace Reasoning { - export const Started = EventV2.define({ - type: "session.next.reasoning.started", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - reasoningID: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), - }, - }) - export type Started = typeof Started.Type - - // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. - export const Delta = EventV2.define({ - type: "session.next.reasoning.delta", - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - reasoningID: Schema.String, - delta: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.reasoning.ended", - ...options, - schema: { - ...Base, - assistantMessageID: SessionMessageID.ID, - reasoningID: Schema.String, - text: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), - }, - }) - export type Ended = typeof Ended.Type -} - -export namespace Tool { - const ToolBase = { - ...Base, - assistantMessageID: SessionMessageID.ID, - callID: Schema.String, - } - - export namespace Input { - export const Started = EventV2.define({ - type: "session.next.tool.input.started", - ...options, - schema: { - ...ToolBase, - name: Schema.String, - }, - }) - export type Started = typeof Started.Type - - // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. - export const Delta = EventV2.define({ - type: "session.next.tool.input.delta", - schema: { - ...ToolBase, - delta: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - export const Ended = EventV2.define({ - type: "session.next.tool.input.ended", - ...options, - schema: { - ...ToolBase, - text: Schema.String, - }, - }) - export type Ended = typeof Ended.Type - } - - export const Called = EventV2.define({ - type: "session.next.tool.called", - ...options, - schema: { - ...ToolBase, - tool: Schema.String, - input: Schema.Record(Schema.String, Schema.Unknown), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - }), - }, - }) - export type Called = typeof Called.Type - - /** - * Replayable bounded running-tool state. Tools should checkpoint semantic - * transitions or at a bounded cadence, not persist every stdout/stderr chunk. - */ - // kilocode_change start - keep legacy persistence decoding out of public event schemas - const ProgressFields = { - ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Any), - content: Schema.Array(ToolContent), - } - export const Progress = EventV2.define({ - type: "session.next.tool.progress", - sync: { - ...options.sync, - codec: Schema.Struct({ ...ProgressFields, content: Schema.Array(StoredToolContent) }), // kilocode_change - }, - schema: ProgressFields, - }) - export type Progress = typeof Progress.Type - - const SuccessFields = { - ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Any), - content: Schema.Array(ToolContent), - outputPaths: Schema.Array(Schema.String).pipe(Schema.optional), - result: Schema.Unknown.pipe(Schema.optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - }), - } - export const Success = EventV2.define({ - type: "session.next.tool.success", - sync: { - ...options.sync, - codec: Schema.Struct({ ...SuccessFields, content: Schema.Array(StoredToolContent) }), // kilocode_change - }, - schema: SuccessFields, - }) - export type Success = typeof Success.Type - // kilocode_change end - - export const Failed = EventV2.define({ - type: "session.next.tool.failed", - ...options, - schema: { - ...ToolBase, - error: UnknownError, - result: Schema.Unknown.pipe(Schema.optional), - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - }), - }, - }) - export type Failed = typeof Failed.Type -} - -export const RetryError = Schema.Struct({ - message: Schema.String, - statusCode: Schema.Finite.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), -}).annotate({ - identifier: "session.next.retry_error", -}) -export type RetryError = typeof RetryError.Type - -export const Retried = EventV2.define({ - type: "session.next.retried", - ...options, - schema: { - ...Base, - attempt: Schema.Finite, - error: RetryError, - }, -}) -export type Retried = typeof Retried.Type - -export namespace Compaction { - export const Started = EventV2.define({ - type: "session.next.compaction.started", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), - }, - }) - export type Started = typeof Started.Type - - export const Delta = EventV2.define({ - type: "session.next.compaction.delta", - schema: { - ...Base, - messageID: SessionMessageID.ID, - text: Schema.String, - }, - }) - export type Delta = typeof Delta.Type - - // kilocode_change start - keep the released v1 event key while storing enough data for both reader generations. - const EndedFields = { - ...Base, - messageID: SessionMessageID.ID.pipe(Schema.optional), - reason: Started.data.fields.reason.pipe(Schema.optional), - text: Schema.String, - recent: Schema.String.pipe(Schema.optional), - include: Schema.String.pipe(Schema.optional), - } - export const Ended = EventV2.define({ - type: "session.next.compaction.ended", - sync: { aggregate: "sessionID", version: 1 }, - schema: EndedFields, - }) - // kilocode_change end - export type Ended = typeof Ended.Type -} - -const DurableDefinitions = [ - AgentSwitched, - ModelSwitched, - Moved, - Prompted, - PromptLifecycle.Admitted, - PromptLifecycle.Promoted, - ContextUpdated, - Synthetic, - Shell.Started, - Shell.Ended, - Step.Started, - Step.Ended, - Step.Failed, - Text.Started, - Text.Ended, - Tool.Input.Started, - Tool.Input.Ended, - Tool.Called, - Tool.Progress, - Tool.Success, - Tool.Failed, - Reasoning.Started, - Reasoning.Ended, - Retried, - Compaction.Started, - Compaction.Ended, -] as const -const EphemeralDefinitions = [ - InterruptRequested, // kilocode_change - preserve downgrade-readable durable streams - Text.Delta, - Tool.Input.Delta, - Reasoning.Delta, - Compaction.Delta, -] as const - -export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) -export type DurableEvent = typeof Durable.Type - -export const All = Schema.Union([...DurableDefinitions, ...EphemeralDefinitions], { mode: "oneOf" }).pipe( - Schema.toTaggedUnion("type"), -) -export type Event = typeof All.Type -export type Type = Event["type"] - -export * as SessionEvent from "./event" +export * from "@opencode-ai/schema/session-event" +export * as SessionEvent from "@opencode-ai/schema/session-event" diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index 9a99145bfb4..5938c377263 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -1,23 +1,34 @@ export * as SessionExecution from "./execution" import { Context, Effect, Layer } from "effect" +import { LayerNode } from "../effect/layer-node" +import { Node } from "../effect/app-node" import { SessionRunner } from "./runner/index" import { SessionSchema } from "./schema" export interface Interface { - /** Explicitly drain one Session, making at least one provider attempt. */ + /** Snapshots active execution owned by this process. */ + readonly active: Effect.Effect> + /** Starts execution while idle or joins the active execution. */ readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect - /** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */ - readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect + /** Registers newly recorded work. Repeated wakeups may coalesce. */ + readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect /** Interrupt active work owned by this process. Idle interruption is a no-op. */ - readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect + readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect } /** Routes execution from a Session ID to the runner owned by that Session's Location. */ export class Service extends Context.Service()("@opencode/v2/SessionExecution") {} +export const node = LayerNode.unbound(Service, Node.tags.values.global) + /** Low-level compatibility layer for callers that only need durable Session recording. */ export const noopLayer = Layer.succeed( Service, - Service.of({ resume: () => Effect.void, wake: () => Effect.void, interrupt: () => Effect.void }), + Service.of({ + active: Effect.succeed(new Set()), + resume: () => Effect.void, + wake: () => Effect.void, + interrupt: () => Effect.void, + }), ) diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index 8f1b1763a05..d874c40832a 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -1,30 +1,35 @@ -import { Effect, Layer } from "effect" -import { LocationServiceMap } from "../../location-layer" +import { Cause, Effect, Layer } from "effect" +import { LocationServiceMap } from "../../location-service-map" +import { makeGlobalNode } from "../../effect/app-node" import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunner } from "../runner" import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { SessionExecution } from "../execution" -import { logFailure } from "../logging" /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ -export const layer = Layer.effect( +const layer = Layer.effect( SessionExecution.Service, Effect.gen(function* () { const store = yield* SessionStore.Service - const locations = yield* LocationServiceMap - const coordinator = yield* SessionRunCoordinator.make({ - drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) { + const locations = yield* LocationServiceMap.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) - return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe( + return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe( Effect.provide(locations.get(session.location)), + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })), + ), ) }), - onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause), }) return SessionExecution.Service.of({ + active: coordinator.active, interrupt: coordinator.interrupt, resume: coordinator.run, wake: coordinator.wake, @@ -32,4 +37,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer)) +export const node = makeGlobalNode({ + service: SessionExecution.Service, + layer, + deps: [SessionStore.node, LocationServiceMap.node], +}) + +export * as SessionExecutionLocal from "./local" diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index 1cf2b3f13b0..fdb12911682 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -11,7 +11,7 @@ type DatabaseService = Database.Interface["db"] const decode = Schema.decodeUnknownEffect(SessionMessage.Message) -const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { +export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const row = yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 2308d064604..66832750fd6 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -8,6 +8,8 @@ import { AbsolutePath, RelativePath } from "../schema" import { WorkspaceV2 } from "../workspace" import { SessionSchema } from "./schema" import { SessionTable } from "./sql" +import { SessionMessage } from "./message" +import { Snapshot } from "../snapshot" export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info { return SessionSchema.Info.make({ @@ -38,6 +40,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined, }), subpath: row.path ? RelativePath.make(row.path) : undefined, + revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined, time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 041c629988a..14b613678db 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -2,11 +2,9 @@ export * as SessionInput from "./input" import { and, asc, eq, isNull, lte } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" +import { Admitted, Delivery } from "@opencode-ai/schema/session-input" import type { Database } from "../database/database" import type { EventV2 } from "../event" -import { EventSequenceTable } from "../event/sql" -import { NonNegativeInt } from "../schema" -import { V2Schema } from "../v2-schema" import { SessionEvent } from "./event" import { SessionMessage } from "./message" import { Prompt } from "./prompt" @@ -15,24 +13,13 @@ import { SessionInputTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -export const Delivery = Schema.Literals(["steer", "queue"]) -export type Delivery = typeof Delivery.Type - -export class Admitted extends Schema.Class("SessionInput.Admitted")({ - admittedSeq: NonNegativeInt, - id: SessionMessage.ID, - sessionID: SessionSchema.ID, - prompt: Prompt, - delivery: Delivery, - timeCreated: V2Schema.DateTimeUtcFromMillis, - promotedSeq: NonNegativeInt.pipe(Schema.optional), -}) {} +export { Admitted, Delivery } const decodePrompt = Schema.decodeUnknownSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt) const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted => - new Admitted({ + Admitted.make({ admittedSeq: row.admitted_seq, id: SessionMessage.ID.make(row.id), sessionID: SessionSchema.ID.make(row.session_id), @@ -65,7 +52,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( if (existing !== undefined) return existing const timestamp = yield* DateTime.now return yield* events - .publish(SessionEvent.PromptLifecycle.Admitted, { + .publish(SessionEvent.PromptAdmitted, { messageID: input.id, sessionID: input.sessionID, timestamp, @@ -74,11 +61,11 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( }) .pipe( Effect.flatMap((event) => - event.seq === undefined + event.durable === undefined ? Effect.die("Prompt admission event is missing aggregate sequence") : Effect.succeed( - new Admitted({ - admittedSeq: event.seq, + Admitted.make({ + admittedSeq: event.durable.seq, id: input.id, sessionID: input.sessionID, prompt: input.prompt, @@ -93,19 +80,6 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( ) }) -export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, -) { - const row = yield* db - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, sessionID)) - .get() - .pipe(Effect.orDie) - return row?.seq ?? -1 -}) - export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( db: DatabaseService, input: { @@ -123,7 +97,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio .where(eq(SessionMessageTable.id, input.id)) .get() .pipe(Effect.orDie) - if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id })) const stored = yield* db .insert(SessionInputTable) .values({ @@ -141,12 +115,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) -export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* ( +export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID readonly prompt: Prompt + readonly delivery: Delivery readonly timeCreated: DateTime.Utc readonly promotedSeq: number }, @@ -164,14 +139,32 @@ export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(functio .returning() .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id })) - const stored = fromRow(updated) - if ( - !matchesPrompt(stored, input) || - DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated) - ) - return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return toMessage(stored) + if (updated) { + const stored = fromRow(updated) + if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return + } + + const stored = yield* find(db, input.id) + if (stored) { + if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq) + return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return + } + + yield* db + .insert(SessionInputTable) + .values({ + id: input.id, + session_id: input.sessionID, + prompt: encodePrompt(input.prompt), + delivery: input.delivery, + admitted_seq: input.promotedSeq, + promoted_seq: input.promotedSeq, + time_created: DateTime.toEpochMillis(input.timeCreated), + }) + .run() + .pipe(Effect.orDie) }) export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( @@ -208,66 +201,17 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) -export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* ( - db: DatabaseService, - event: EventV2.Payload, -) { - if ( - Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) || - Schema.is(SessionEvent.PromptLifecycle.Promoted)(event) - ) - return - const id = reservedID(event) - if (id === undefined) return - const admitted = yield* db - .select({ id: SessionInputTable.id }) - .from(SessionInputTable) - .where(eq(SessionInputTable.id, id)) - .get() - .pipe(Effect.orDie) - if (admitted === undefined) return - return yield* Effect.die(new LifecycleConflict({ id })) -}) - -const reservedID = (event: EventV2.Payload) => { - if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID - if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID - if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID - if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID -} - -export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* ( - db: DatabaseService, - input: { - readonly id: SessionMessage.ID +const matchesProjection = ( + input: Admitted, + expected: { readonly sessionID: SessionSchema.ID readonly prompt: Prompt readonly delivery: Delivery readonly timeCreated: DateTime.Utc - readonly promotedSeq: number }, -) { - const inserted = yield* db - .insert(SessionInputTable) - .values({ - id: input.id, - session_id: input.sessionID, - admitted_seq: input.promotedSeq, - prompt: encodePrompt(input.prompt), - delivery: input.delivery, - promoted_seq: input.promotedSeq, - time_created: DateTime.toEpochMillis(input.timeCreated), - }) - .onConflictDoNothing() - .returning() - .get() - .pipe(Effect.orDie) - if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input") - return fromRow(inserted) -}) +) => + equivalent(input, expected) && + DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated) const publish = Effect.fn("SessionInput.publish")(function* ( db: DatabaseService, @@ -276,18 +220,19 @@ const publish = Effect.fn("SessionInput.publish")(function* ( rows: ReadonlyArray, ) { for (const row of rows) { + const id = SessionMessage.ID.make(row.id) yield* events - .publish(SessionEvent.PromptLifecycle.Promoted, { + .publish(SessionEvent.Prompted, { sessionID, - timestamp: yield* DateTime.now, - messageID: SessionMessage.ID.make(row.id), + timestamp: DateTime.makeUnsafe(row.time_created), + messageID: id, prompt: decodePrompt(row.prompt), - timeCreated: DateTime.makeUnsafe(row.time_created), + delivery: row.delivery, }) .pipe( Effect.catchDefect((defect) => defect instanceof LifecycleConflict - ? find(db, SessionMessage.ID.make(row.id)).pipe( + ? find(db, id).pipe( Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)), ) : Effect.die(defect), @@ -341,13 +286,3 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun .pipe(Effect.orDie) return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true)) }) - -const toMessage = (input: Admitted) => - new SessionMessage.User({ - id: input.id, - type: "user", - text: input.prompt.text, - files: input.prompt.files, - agents: input.prompt.agents, - time: { created: input.timeCreated }, - }) diff --git a/packages/core/src/session/logging.ts b/packages/core/src/session/logging.ts deleted file mode 100644 index c579ec15dcd..00000000000 --- a/packages/core/src/session/logging.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Cause, Effect } from "effect" -import { SessionSchema } from "./schema" - -export const logFailure = ( - message: "Failed to drain Session" | "Failed to wake Session", - sessionID: SessionSchema.ID, - cause: Cause.Cause, -) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID })) diff --git a/packages/core/src/session/message-id.ts b/packages/core/src/session/message-id.ts deleted file mode 100644 index f06fc0fcd5d..00000000000 --- a/packages/core/src/session/message-id.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * as SessionMessageID from "./message-id" - -import { Schema } from "effect" -import { withStatics } from "../schema" -import { Identifier } from "../util/identifier" - -export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( - Schema.brand("Session.Message.ID"), - withStatics((schema) => ({ - create: () => schema.make("msg_" + Identifier.ascending()), - })), -) -export type ID = typeof ID.Type diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 647fd607da6..69dbf167b23 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -102,7 +102,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { yield* SessionEvent.All.match(event, { "session.next.agent.switched": (event) => { return adapter.appendMessage( - new SessionMessage.AgentSwitched({ + SessionMessage.AgentSwitched.make({ id: event.data.messageID, type: "agent-switched", metadata: event.metadata, @@ -113,7 +113,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.model.switched": (event) => { return adapter.appendMessage( - new SessionMessage.ModelSwitched({ + SessionMessage.ModelSwitched.make({ id: event.data.messageID, type: "model-switched", metadata: event.metadata, @@ -125,7 +125,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.moved": () => Effect.void, "session.next.prompted": (event) => { return adapter.appendMessage( - new SessionMessage.User({ + SessionMessage.User.make({ id: event.data.messageID, type: "user", metadata: event.metadata, @@ -137,11 +137,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }, "session.next.prompt.admitted": () => Effect.void, - "session.next.prompt.promoted": () => Effect.void, - "session.next.interrupt.requested": () => Effect.void, "session.next.context.updated": (event) => adapter.appendMessage( - new SessionMessage.System({ + SessionMessage.System.make({ id: event.data.messageID, type: "system", text: event.data.text, @@ -150,7 +148,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ), "session.next.synthetic": (event) => { return adapter.appendMessage( - new SessionMessage.Synthetic({ + SessionMessage.Synthetic.make({ sessionID: event.data.sessionID, text: event.data.text, id: event.data.messageID, @@ -161,7 +159,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.shell.started": (event) => { return adapter.appendMessage( - new SessionMessage.Shell({ + SessionMessage.Shell.make({ id: event.data.messageID, type: "shell", metadata: event.metadata, @@ -196,7 +194,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) } yield* adapter.appendMessage( - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: event.data.assistantMessageID, type: "assistant", agent: event.data.agent, @@ -214,7 +212,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { draft.finish = event.data.finish draft.cost = event.data.cost draft.tokens = event.data.tokens - if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot } + if (event.data.snapshot || event.data.files) + draft.snapshot = { + ...draft.snapshot, + end: event.data.snapshot, + files: event.data.files ? Array.from(event.data.files) : undefined, + } }) }, "session.next.step.failed": (event) => { @@ -227,7 +230,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.text.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( - castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })), + castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), ) }) }, @@ -247,12 +250,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: event.data.callID, name: event.data.name, time: { created: event.data.timestamp }, - state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }), + state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }), }), ), ) @@ -272,7 +275,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { match.provider = event.data.provider match.time.ran = event.data.timestamp match.state = castDraft( - new SessionMessage.ToolStateRunning({ + SessionMessage.ToolStateRunning.make({ status: "running", input: event.data.input, structured: {}, @@ -302,7 +305,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } match.time.completed = event.data.timestamp match.state = castDraft( - new SessionMessage.ToolStateCompleted({ + SessionMessage.ToolStateCompleted.make({ status: "completed", input: match.state.input, structured: event.data.structured, @@ -325,7 +328,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } match.time.completed = event.data.timestamp match.state = castDraft( - new SessionMessage.ToolStateError({ + SessionMessage.ToolStateError.make({ status: "error", error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, @@ -341,11 +344,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: event.data.reasoningID, text: "", providerMetadata: event.data.providerMetadata, + time: { created: event.data.timestamp }, }), ), ) @@ -362,6 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { const match = latestReasoning(draft, event.data.reasoningID) if (match) { match.text = event.data.text + match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) @@ -372,7 +377,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.compaction.ended": (event) => { if (event.data.messageID === undefined || event.data.reason === undefined) return Effect.void // kilocode_change return adapter.appendMessage( - new SessionMessage.Compaction({ + SessionMessage.Compaction.make({ id: event.data.messageID, type: "compaction", metadata: event.metadata, @@ -383,6 +388,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, + "session.next.revert.staged": () => Effect.void, + "session.next.revert.cleared": () => Effect.void, + "session.next.revert.committed": () => Effect.void, }) }) } diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index c5b621a08da..47f1baac052 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -1,193 +1,2 @@ export * as SessionMessage from "./message" - -import { Schema } from "effect" -import { ProviderMetadata, ToolContent } from "@opencode-ai/llm" -import { ModelV2 } from "../model" -import { V2Schema } from "../v2-schema" -import { SessionEvent } from "./event" -import { Prompt } from "./prompt" -import { SessionMessageID } from "./message-id" - -export const ID = SessionMessageID.ID -export type ID = typeof ID.Type - -const Base = { - id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - }), -} - -export class AgentSwitched extends Schema.Class("Session.Message.AgentSwitched")({ - ...Base, - type: Schema.Literal("agent-switched"), - agent: SessionEvent.AgentSwitched.data.fields.agent, -}) {} - -export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ - ...Base, - type: Schema.Literal("model-switched"), - model: ModelV2.Ref, -}) {} - -export class User extends Schema.Class("Session.Message.User")({ - ...Base, - text: Prompt.fields.text, - files: Prompt.fields.files, - agents: Prompt.fields.agents, - type: Schema.Literal("user"), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - }), -}) {} - -export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ - ...Base, - sessionID: SessionEvent.Synthetic.data.fields.sessionID, - text: SessionEvent.Synthetic.data.fields.text, - type: Schema.Literal("synthetic"), -}) {} - -export class System extends Schema.Class("Session.Message.System")({ - ...Base, - type: Schema.Literal("system"), - text: SessionEvent.ContextUpdated.data.fields.text, -}) {} - -export class Shell extends Schema.Class("Session.Message.Shell")({ - ...Base, - type: Schema.Literal("shell"), - callID: SessionEvent.Shell.Started.data.fields.callID, - command: SessionEvent.Shell.Started.data.fields.command, - output: Schema.String, - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - }), -}) {} - -export class ToolStatePending extends Schema.Class("Session.Message.ToolState.Pending")({ - status: Schema.Literal("pending"), - input: Schema.String, -}) {} - -export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Unknown), - structured: Schema.Record(Schema.String, Schema.Any), - content: ToolContent.pipe(Schema.Array), -}) {} - -export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Unknown), - attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), - content: ToolContent.pipe(Schema.Array), - outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths, - structured: Schema.Record(Schema.String, Schema.Any), - result: SessionEvent.Tool.Success.data.fields.result, -}) {} - -export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Unknown), - content: ToolContent.pipe(Schema.Array), - structured: Schema.Record(Schema.String, Schema.Any), - error: SessionEvent.UnknownError, - result: SessionEvent.Tool.Failed.data.fields.result, -}) {} - -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( - Schema.toTaggedUnion("status"), -) -export type ToolState = Schema.Schema.Type - -export class AssistantTool extends Schema.Class("Session.Message.Assistant.Tool")({ - type: Schema.Literal("tool"), - id: Schema.String, - name: Schema.String, - provider: Schema.Struct({ - executed: Schema.Boolean, - metadata: ProviderMetadata.pipe(Schema.optional), - resultMetadata: ProviderMetadata.pipe(Schema.optional), - }).pipe(Schema.optional), - state: ToolState, - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - }), -}) {} - -export class AssistantText extends Schema.Class("Session.Message.Assistant.Text")({ - type: Schema.Literal("text"), - id: Schema.String, - text: Schema.String, -}) {} - -export class AssistantReasoning extends Schema.Class("Session.Message.Assistant.Reasoning")({ - type: Schema.Literal("reasoning"), - id: Schema.String, - text: Schema.String, - providerMetadata: ProviderMetadata.pipe(Schema.optional), -}) {} - -export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( - Schema.toTaggedUnion("type"), -) -export type AssistantContent = Schema.Schema.Type - -export class Assistant extends Schema.Class("Session.Message.Assistant")({ - ...Base, - type: Schema.Literal("assistant"), - agent: Schema.String, - model: SessionEvent.Step.Started.data.fields.model, - content: AssistantContent.pipe(Schema.Array), - snapshot: Schema.Struct({ - start: Schema.String.pipe(Schema.optional), - end: Schema.String.pipe(Schema.optional), - }).pipe(Schema.optional), - finish: Schema.String.pipe(Schema.optional), - cost: Schema.Finite.pipe(Schema.optional), - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }).pipe(Schema.optional), - error: SessionEvent.Step.Failed.data.fields.error.pipe(Schema.optional), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - }), -}) {} - -export class Compaction extends Schema.Class("Session.Message.Compaction")({ - type: Schema.Literal("compaction"), - reason: SessionEvent.Compaction.Started.data.fields.reason, - summary: Schema.String, - recent: Schema.String, - ...Base, -}) {} - -export const Message = Schema.Union([ - AgentSwitched, - ModelSwitched, - User, - Synthetic, - System, - Shell, - Assistant, - Compaction, -]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Session.Message" }) - -export type Message = Schema.Schema.Type - -export type Type = Message["type"] +export * from "@opencode-ai/schema/session-message" diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 3855817e800..1467adb5a7b 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,10 +1,10 @@ export * as SessionProjector from "./projector" -import { and, desc, eq, isNotNull, sql } from "drizzle-orm" // kilocode_change +import { and, desc, eq, isNotNull, gt, or, sql } from "drizzle-orm" // kilocode_change import { DateTime, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/app-node" import { SessionEvent } from "./event" import { SessionV1 } from "../v1/session" import { WorkspaceTable } from "../control-plane/workspace.sql" @@ -14,8 +14,9 @@ import * as StoredMessage from "../kilocode/session-message" // kilocode_change import { SessionInput } from "./input" import { WorkspaceV2 } from "../workspace" import { SessionContextEpoch } from "./context-epoch" -import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql" +import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql" import type { DeepMutable } from "../schema" +import * as PromptCompat from "../kilocode/session/prompt-promoted" // kilocode_change - released replay key type DatabaseService = Database.Interface["db"] @@ -25,7 +26,6 @@ const encodeMessage = (message: SessionMessage.Message) => StoredMessage.encode(Schema.encodeSync(SessionMessage.Message)(message)) as (typeof SessionMessage.Message)["Encoded"] // kilocode_change end -class PromptAlreadyProjected extends Error {} export class SessionAlreadyProjected extends Error {} type Usage = { @@ -71,7 +71,7 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning, tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read, tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write, - revert: info.revert ?? null, + revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null, permission: info.permission ? [...info.permission] : undefined, time_created: info.time.created, time_updated: info.time.updated, @@ -119,7 +119,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage(StoredMessage.normalize({ ...row.data, id: row.id, type: row.type })) // kilocode_change const updateMessage = (message: SessionMessage.Message) => { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -211,7 +211,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) { } function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db @@ -220,7 +220,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: id: SessionMessage.ID.make(id), session_id: event.data.sessionID, type, - seq: event.seq, + seq: event.durable.seq, time_created: DateTime.toEpochMillis(message.time.created), data, }) @@ -228,11 +228,10 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: .pipe(Effect.orDie) } -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event)) yield* events.project(SessionV1.Event.Created, (event) => Effect.gen(function* () { const stored = yield* db @@ -349,19 +348,14 @@ export const layer = Layer.effectDiscard( if (next) yield* applyUsage(db, sessionID, next) }), ) - yield* events.project(SessionEvent.AgentSwitched, (event) => { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") - return db + yield* events.project(SessionEvent.AgentSwitched, (event) => + db .update(SessionTable) .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .where(eq(SessionTable.id, event.data.sessionID)) .run() - .pipe( - Effect.orDie, - Effect.andThen(run(db, event)), - Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), - ) - }) + .pipe(Effect.orDie, Effect.andThen(run(db, event))), + ) yield* events.project(SessionEvent.ModelSwitched, (event) => Effect.gen(function* () { yield* db @@ -371,40 +365,27 @@ export const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie) yield* run(db, event) - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq) }), ) yield* events.project(SessionEvent.Prompted, (event) => Effect.gen(function* () { - const messageID = event.data.messageID - const existing = yield* db - .select({ id: SessionMessageTable.id }) - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, messageID)) - .get() - .pipe(Effect.orDie) - if (existing) return yield* Effect.die(new PromptAlreadyProjected()) - yield* run(db, event) - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - yield* SessionInput.projectLegacyPrompted(db, { - id: messageID, + if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") + yield* SessionInput.projectPrompted(db, { + id: event.data.messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, delivery: event.data.delivery, timeCreated: event.data.timestamp, - promotedSeq: event.seq, + promotedSeq: event.durable.seq, }) + yield* run(db, event) }), ) - yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) => + yield* events.project(SessionEvent.PromptAdmitted, (event) => Effect.gen(function* () { - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* SessionInput.projectAdmitted(db, { - admittedSeq: event.seq, + admittedSeq: event.durable.seq, id: event.data.messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, @@ -413,30 +394,8 @@ export const layer = Layer.effectDiscard( }) }), ) - yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) => - Effect.gen(function* () { - if (event.seq === undefined) - return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - yield* insertMessage( - db, - event, - yield* SessionInput.projectPromoted(db, { - id: event.data.messageID, - sessionID: event.data.sessionID, - prompt: event.data.prompt, - timeCreated: event.data.timeCreated, - promotedSeq: event.seq, - }), - ) - }), - ) - yield* events.project(SessionEvent.InterruptRequested, () => Effect.void) - yield* events.project(SessionEvent.ContextUpdated, (event) => { - if (!event.replay || event.seq === undefined) return run(db, event) - return run(db, event).pipe( - Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), - ) - }) + yield* events.project(PromptCompat.definition, (event) => PromptCompat.project(db, event, (next) => run(db, next))) // kilocode_change - replay released two-step promotions + yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) @@ -454,17 +413,69 @@ export const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) - yield* events.project(SessionEvent.Compaction.Ended, (event) => { - if (event.data.messageID === undefined || event.data.reason === undefined) return Effect.void // kilocode_change - const seq = event.seq - if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") - return Effect.gen(function* () { - yield* run(db, event) - yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq) - }) - }) + yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) + yield* events.project(SessionEvent.RevertEvent.Staged, (event) => + db + .update(SessionTable) + .set({ + revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined }, + time_updated: DateTime.toEpochMillis(event.data.timestamp), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie, Effect.asVoid), + ) + yield* events.project(SessionEvent.RevertEvent.Cleared, (event) => + db + .update(SessionTable) + .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie, Effect.asVoid), + ) + yield* events.project(SessionEvent.RevertEvent.Committed, (event) => + Effect.gen(function* () { + const boundary = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, event.data.sessionID), + eq(SessionMessageTable.id, event.data.messageID), + ), + ) + .get() + .pipe(Effect.orDie) + // kilocode_change - Kilo keeps session_message.seq nullable for released-data compatibility + if (!boundary || boundary.seq === null) + return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`) + yield* db + .delete(SessionMessageTable) + .where( + and(eq(SessionMessageTable.session_id, event.data.sessionID), gt(SessionMessageTable.seq, boundary.seq)), + ) + .run() + .pipe(Effect.orDie) + yield* db + .delete(SessionInputTable) + .where( + and( + eq(SessionInputTable.session_id, event.data.sessionID), + or(gt(SessionInputTable.admitted_seq, boundary.seq), gt(SessionInputTable.promoted_seq, boundary.seq)), + ), + ) + .run() + .pipe(Effect.orDie) + yield* db + .update(SessionTable) + .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + yield* SessionContextEpoch.reset(db, event.data.sessionID) + }), + ) }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make(layer, [EventV2.node, Database.node]) +export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/session/prompt.ts b/packages/core/src/session/prompt.ts index a0653c51e60..b079349c566 100644 --- a/packages/core/src/session/prompt.ts +++ b/packages/core/src/session/prompt.ts @@ -1,46 +1 @@ -import * as Schema from "effect/Schema" - -export class Source extends Schema.Class("Prompt.Source")({ - start: Schema.Finite, - end: Schema.Finite, - text: Schema.String, -}) {} - -export class FileAttachment extends Schema.Class("Prompt.FileAttachment")({ - uri: Schema.String, - mime: Schema.String, - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), -}) { - static create(input: FileAttachment) { - return new FileAttachment({ - uri: input.uri, - mime: input.mime, - name: input.name, - description: input.description, - source: input.source, - }) - } -} - -export class AgentAttachment extends Schema.Class("Prompt.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), -}) {} - -export class Prompt extends Schema.Class("Prompt")({ - text: Schema.String, - files: Schema.Array(FileAttachment).pipe(Schema.optional), - agents: Schema.Array(AgentAttachment).pipe(Schema.optional), -}) { - static readonly equivalence = Schema.toEquivalence(Prompt) - - static fromUserMessage(input: Pick) { - return new Prompt({ - text: input.text, - ...(input.files === undefined ? {} : { files: input.files }), - ...(input.agents === undefined ? {} : { agents: input.agents }), - }) - } -} +export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt" diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts new file mode 100644 index 00000000000..e3104b1b059 --- /dev/null +++ b/packages/core/src/session/revert.ts @@ -0,0 +1,125 @@ +export * as SessionRevert from "./revert" + +import { and, asc, eq, gt } from "drizzle-orm" +import { DateTime, Effect, Schema } from "effect" +import { Database } from "../database/database" +import { EventV2 } from "../event" +import { RelativePath } from "../schema" +import { Snapshot } from "../snapshot" +import { SessionEvent } from "./event" +import { SessionMessage } from "./message" +import * as StoredMessage from "../kilocode/session-message" // kilocode_change +import { SessionSchema } from "./schema" +import { SessionMessageTable } from "./sql" + +export class MessageNotFoundError extends Schema.TaggedErrorClass()( + "Session.MessageNotFoundError", + { + sessionID: SessionSchema.ID, + messageID: SessionMessage.ID, + }, +) {} + +interface BoundaryInput { + readonly sessionID: SessionSchema.ID + readonly messageID: SessionMessage.ID +} + +const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) { + const db = (yield* Database.Service).db + const boundary = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID))) + .get() + .pipe(Effect.orDie) + // kilocode_change - Kilo keeps session_message.seq nullable for released-data compatibility + if (!boundary || boundary.seq === null) return yield* new MessageNotFoundError(input) + const rows = yield* db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, input.sessionID), + eq(SessionMessageTable.type, "assistant"), + gt(SessionMessageTable.seq, boundary.seq), + ), + ) + .orderBy(asc(SessionMessageTable.seq)) + .all() + .pipe(Effect.orDie) + const decode = Schema.decodeUnknownEffect(SessionMessage.Message) + const files = new Map() + for (const row of rows) { + const message = yield* decode(StoredMessage.normalize({ ...row.data, id: row.id, type: row.type })).pipe( + Effect.orDie, + ) // kilocode_change - released rows persist legacy tool content + if (message.type !== "assistant" || !message.snapshot?.start) continue + for (const file of message.snapshot.files ?? []) + if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start)) + } + return files +}) + +export const stage = Effect.fn("SessionRevert.stage")(function* (input: { + readonly session: SessionSchema.Info + readonly messageID: SessionMessage.ID + readonly files?: boolean +}) { + const snapshot = yield* Snapshot.Service + const events = yield* EventV2.Service + const original = input.session.revert?.snapshot + ? Snapshot.ID.make(input.session.revert.snapshot) + : yield* snapshot.capture() + const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID }) + const restore = new Map() + if (original) { + for (const file of input.session.revert?.files ?? []) restore.set(file.path, original) + } + if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree) + if (restore.size) yield* snapshot.restore({ files: restore }) + const paths = input.files === false ? [] : Array.from(next.keys()) + const files = original + ? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths }) + : [] + const revert = { + messageID: input.messageID, + snapshot: original, + diff: files + .map((file) => file.patch) + .join("") + .trim(), + files, + } satisfies SessionSchema.Info["revert"] + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID: input.session.id, + timestamp: yield* DateTime.now, + revert, + }) + return revert +}) + +export const clear = Effect.fn("SessionRevert.clear")(function* (session: SessionSchema.Info) { + if (!session.revert) return + const snapshot = yield* Snapshot.Service + const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined + if (original) + yield* snapshot.restore({ + files: new Map((session.revert.files ?? []).map((file) => [file.path, original])), + }) + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.RevertEvent.Cleared, { + sessionID: session.id, + timestamp: yield* DateTime.now, + }) +}) + +export const commit = Effect.fn("SessionRevert.commit")(function* (session: SessionSchema.Info) { + if (!session.revert) return + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.RevertEvent.Committed, { + sessionID: session.id, + messageID: session.revert.messageID, + timestamp: yield* DateTime.now, + }) +}) diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index d52b63e8f11..2f89aff9e3d 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -1,106 +1,45 @@ export * as SessionRunCoordinator from "./run-coordinator" -import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect" -import { SessionRunner } from "./runner" -import { logFailure } from "./logging" -import { SessionSchema } from "./schema" +import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" -export type Mode = "run" | "wake" - -/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */ -type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number } - -/** - * Runs at most one drain chain per key while allowing different keys to drain concurrently. - * - * For each key: - * - * idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle - * - * `run` is an explicit drain request. It starts a chain or joins the current chain and - * upgrades a pending follow-up so the caller receives explicit-run semantics. - * - * `wake` reports that durable work may now be available. It starts a chain while idle or - * requests one coalesced follow-up while draining. Repeated wakes collapse together. - * - * `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt - * boundary are suppressed; advisory wakes after the boundary run after cleanup. - */ -export interface Coordinator { - /** Starts or joins one explicit drain generation. */ - readonly run: (key: Key) => Effect.Effect - /** Coalesces one wake-up after durable work is recorded. */ - readonly wake: (key: Key, seq?: number) => Effect.Effect - /** Waits until the current ownership chain settles. */ - readonly awaitIdle: (key: Key) => Effect.Effect - /** Interrupts the active ownership chain without automatically draining pending wakes. */ - readonly interrupt: (key: Key, seq?: number) => Effect.Effect +/** Serializes execution for each key while allowing different keys to run concurrently. */ +export interface Coordinator { + /** Snapshots keys with an execution owned by this coordinator. */ + readonly active: Effect.Effect> + /** Starts execution while idle or joins the active execution. */ + readonly run: (key: Key) => Effect.Effect + /** Registers one coalesced follow-up after newly recorded work. */ + readonly wake: (key: Key) => Effect.Effect + /** Stops active execution and waits for its cleanup. */ + readonly interrupt: (key: Key) => Effect.Effect } -/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */ -type Entry = { - readonly done: Deferred.Deferred - readonly settled: Deferred.Deferred> - current: Demand - pending?: Demand - explicitWaiter?: Deferred.Deferred - interruptSeq?: number +type Entry = { + readonly done: Deferred.Deferred owner?: Fiber.Fiber + pendingWake: boolean stopping: boolean } -/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */ -const coalesce = (left: Demand | undefined, right: Demand): Demand => { - if (left?._tag === "run" || right._tag === "run") return { _tag: "run" } - return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) } -} - -const maxSeq = (left: number | undefined, right: number | undefined) => { - if (left === undefined) return right - if (right === undefined) return left - return Math.max(left, right) -} - -/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */ -export const make = (options: { - readonly drain: (key: Key, mode: Mode) => Effect.Effect - readonly onFailure?: (key: Key, cause: Cause.Cause) => Effect.Effect -}): Effect.Effect, never, Scope.Scope> => +export const make = (options: { + readonly drain: (key: Key, force: boolean) => Effect.Effect +}): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const active = new Map>() - const interruptSeq = new Map() - const report = yield* FiberSet.makeRuntime() + const active = new Map>() const fork = yield* FiberSet.makeRuntime() - const shutdown = Deferred.makeUnsafe() - let closed = false - yield* Effect.addFinalizer(() => - Effect.sync(() => { - closed = true - Deferred.doneUnsafe(shutdown, Effect.void) - active.clear() - interruptSeq.clear() - }), - ) - const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred): Entry => ({ - done: Deferred.makeUnsafe(), - settled: Deferred.makeUnsafe>(), - current, - explicitWaiter, + const makeEntry = (): Entry => ({ + done: Deferred.makeUnsafe(), + pendingWake: false, stopping: false, }) - const start = (key: Key, entry: Entry, demand: Demand, successor = false) => { + const start = (key: Key, entry: Entry, force: boolean, successor = false) => { const ready = Deferred.makeUnsafe() - const drain = Effect.suspend(() => options.drain(key, demand._tag)) - // Initial work retains immediate-start behavior but cannot run before ownership is published. - // Observer-started successors yield once so synchronous drains cannot recurse on the JS stack. const owner = fork( - (successor - ? Effect.yieldNow.pipe(Effect.andThen(drain)) - : Deferred.await(ready).pipe(Effect.andThen(drain)) - ).pipe( - Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))), + (successor ? Effect.yieldNow : Deferred.await(ready)).pipe( + Effect.andThen(Effect.suspend(() => options.drain(key, force))), + Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))), Effect.exit, Effect.asVoid, ), @@ -109,176 +48,57 @@ export const make = (options: { if (!successor) Deferred.doneUnsafe(ready, Effect.void) } - const settle = (key: Key, entry: Entry, demand: Demand, exit: Exit.Exit) => { - if (closed) { - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) - return - } - if (demand._tag === "run" && entry.explicitWaiter !== undefined) { - Deferred.doneUnsafe(entry.explicitWaiter, exit) - entry.explicitWaiter = undefined - } - if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) { - Deferred.doneUnsafe(entry.explicitWaiter, exit) - entry.explicitWaiter = undefined - } - if (active.get(key) !== entry) { - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) - return - } - if (exit._tag === "Success" && !entry.stopping) { - if (entry.pending !== undefined) { - const pending = entry.pending - entry.pending = undefined - entry.current = pending - start(key, entry, pending, true) - return - } - active.delete(key) - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) + const settle = (key: Key, entry: Entry, exit: Exit.Exit) => { + if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) { + entry.pendingWake = false + start(key, entry, false, true) return } - const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined + const successor = entry.pendingWake ? makeEntry() : undefined if (successor === undefined) active.delete(key) - else active.set(key, successor) - if (successor !== undefined) start(key, successor, successor.current, true) - Deferred.doneUnsafe(entry.done, exit) - Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) - if ( - exit._tag === "Failure" && - !(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) && - demand._tag === "wake" && - options.onFailure !== undefined - ) { - report(Effect.suspend(() => options.onFailure!(key, exit.cause))) + else { + active.set(key, successor) + start(key, successor, false, true) } + Deferred.doneUnsafe(entry.done, exit) } - const wake = (key: Key, seq?: number) => - Effect.sync(() => { - if (closed) return - if (!isAfterInterrupt(key, seq)) return + const run = (key: Key): Effect.Effect => + Effect.uninterruptibleMask((restore) => { const entry = active.get(key) if (entry !== undefined) { - if (!acceptsWake(entry, seq)) return - entry.pending = coalesce(entry.pending, { _tag: "wake", seq }) + if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key)))) + return restore(Deferred.await(entry.done)) + } + + const next = makeEntry() + active.set(key, next) + start(key, next, true) + return restore(Deferred.await(next.done)) + }) + + const wake = (key: Key) => + Effect.sync(() => { + const entry = active.get(key) + if (entry !== undefined) { + entry.pendingWake = true return } - const next = makeEntry({ _tag: "wake", seq }) + const next = makeEntry() active.set(key, next) - start(key, next, next.current) + start(key, next, false) }) - const awaitIdle = (key: Key): Effect.Effect => - Effect.gen(function* () { - let firstFailure: Cause.Cause | undefined - while (!closed) { - const entry = active.get(key) - if (entry === undefined) break - const exit = yield* Effect.raceFirst( - Deferred.await(entry.settled), - Deferred.await(shutdown).pipe(Effect.as(Exit.void)), - ) - if (closed) break - if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause - } - if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure) - }) - - const interrupt = (key: Key, seq?: number): Effect.Effect => + const interrupt = (key: Key): Effect.Effect => Effect.suspend(() => { const entry = active.get(key) - const latest = interruptSeq.get(key) - if (seq !== undefined && latest !== undefined && seq <= latest) - return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void - if (seq !== undefined) interruptSeq.set(key, seq) if (entry?.owner === undefined) return Effect.void - if ( - seq !== undefined && - entry.current._tag === "wake" && - entry.current.seq !== undefined && - entry.current.seq > seq - ) - return Effect.void - if (entry.stopping) { - entry.interruptSeq = maxSeq(entry.interruptSeq, seq) - suppressPendingAtOrBefore(entry, seq) - return Fiber.interrupt(entry.owner) - } entry.stopping = true - entry.interruptSeq = seq - suppressPendingAtOrBefore(entry, seq) + entry.pendingWake = false return Fiber.interrupt(entry.owner) }) - return { run, wake, awaitIdle, interrupt } - - function run(key: Key): Effect.Effect { - return Effect.uninterruptibleMask((restore) => { - if (closed) return Effect.interrupt - const entry = active.get(key) - if (entry !== undefined) { - if (entry.stopping) { - return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key)))) - } - if (entry.current._tag === "wake") { - entry.pending = coalesce(entry.pending, { _tag: "run" }) - entry.explicitWaiter ??= Deferred.makeUnsafe() - return restore(awaitRun(entry.explicitWaiter)) - } - return restore(awaitRun(entry.done)) - } - - const next = makeEntry({ _tag: "run" }) - active.set(key, next) - start(key, next, next.current) - return restore(awaitRun(next.done)) - }) - } - - function awaitRun(done: Deferred.Deferred): Effect.Effect { - return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt))) - } - - function acceptsWake(entry: Entry, seq: number | undefined) { - return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq) - } - - function isAfterInterrupt(key: Key, seq: number | undefined) { - const latest = interruptSeq.get(key) - return latest === undefined || (seq !== undefined && seq > latest) - } - - function suppressPendingAtOrBefore(entry: Entry, seq: number | undefined) { - if ( - entry.pending?._tag === "wake" && - seq !== undefined && - entry.pending.seq !== undefined && - entry.pending.seq > seq - ) - return - entry.pending = undefined - } + return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt } }) - -export interface Interface extends Coordinator {} - -export class Service extends Context.Service()("@opencode/v2/SessionRunCoordinator") {} - -export const layer = Layer.effect( - Service, - SessionRunner.Service.pipe( - Effect.flatMap((runner) => - make({ - drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }), - onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause), - }), - ), - Effect.map(Service.of), - ), -) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 4060cc6b044..634075dd91b 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -6,7 +6,6 @@ import { SessionSchema } from "../schema" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" import type { SystemContext } from "../../system-context/index" -import type { SessionContextEpoch } from "../context-epoch" import type { ToolOutputStore } from "../../tool-output-store" export type RunError = @@ -15,7 +14,6 @@ export type RunError = | MessageDecodeError | ContextSnapshotDecodeError | SystemContext.InitializationBlocked - | SessionContextEpoch.AgentReplacementBlocked | ToolOutputStore.Error /** Runs one local continuation from already-recorded Session history. */ @@ -23,7 +21,7 @@ export interface Interface { /** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ readonly run: (input: { readonly sessionID: SessionSchema.ID - readonly force?: boolean + readonly force: boolean }) => Effect.Effect } diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 233a4aa4d82..c340e28f6ed 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -8,7 +8,7 @@ import { isContextOverflowFailure, type ProviderErrorEvent, } from "@opencode-ai/llm" -import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect" +import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { Database } from "../../database/database" @@ -35,6 +35,9 @@ import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" import { MAX_STEPS_PROMPT } from "./max-steps" +import { Snapshot } from "../../snapshot" +import { makeLocationNode } from "../../effect/app-node" +import { llmClient } from "../../effect/app-node-platform" /** * Runs one durable coding-agent Session until it settles. @@ -79,14 +82,14 @@ import { MAX_STEPS_PROMPT } from "./max-steps" * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * * Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here. - * Durable activity recovery remains a separate future slice with an explicit retry policy. + * Durable continuation recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. */ -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -100,6 +103,7 @@ export const layer = Layer.effect( const skillGuidance = yield* SkillGuidance.Service const referenceGuidance = yield* ReferenceGuidance.Service const config = yield* Config.Service + const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() }) const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { @@ -141,10 +145,10 @@ export const layer = Layer.effect( cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) type TurnTransition = - // Request preparation observed a concurrent Session change and must restart from durable state. - | { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery } + // Automatic compaction completed; rebuild the request from compacted history. + | { readonly _tag: "ContinueAfterCompaction"; readonly step: number } // Overflow compaction completed; rebuild once through the path without overflow recovery. - | { readonly _tag: "ContinueAfterOverflowCompaction" } + | { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number } class TurnTransitionError extends Error { constructor(readonly transition: TurnTransition) { @@ -152,20 +156,10 @@ export const layer = Layer.effect( } } - const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) => - new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion }) - const continueAfterOverflowCompaction = new TurnTransitionError({ - _tag: "ContinueAfterOverflowCompaction", - }) + const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step }) + const continueAfterOverflowCompaction = (step: number) => + new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) - const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) => - Effect.catchDefect((defect) => - defect instanceof SessionContextEpoch.AgentMismatch - ? Effect.die(rebuildPreparedTurn(promotion)) - : Effect.die(defect), - ) - - const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref)) const loadSystemContext = (agent: AgentV2.Selection) => Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { concurrency: "unbounded", @@ -181,40 +175,27 @@ export const layer = Layer.effect( if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt const agent = yield* agents.select(session.agent) - const initialized = yield* SessionContextEpoch.initialize( - db, - loadSystemContext(agent), - session.id, - session.location, - agent.id, - ).pipe(retryAgentMismatch(promotion)) + const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id, location) // kilocode_change const toolFibers = yield* FiberSet.make() let needsContinuation = false + let currentStep = step if (promotion) { - const cutoff = yield* SessionInput.latestSeq(db, session.id) - if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + const cutoff = yield* EventV2.latestSequence(db, session.id) + let promoted = 0 + if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff) if (promotion === "queue") { - yield* SessionInput.promoteNextQueued(db, events, session.id) - yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id)) + promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff) } + if (promoted > 0) currentStep = 1 } const system = - initialized ?? - (yield* SessionContextEpoch.prepare( - db, - events, - loadSystemContext(agent), - session.id, - session.location, - agent.id, - ).pipe(retryAgentMismatch(undefined))) - const current = yield* getSession(sessionID) - if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model)) - return yield* Effect.die(rebuildPreparedTurn()) + // kilocode_change - pass the expected Location so a concurrent move aborts the epoch + initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id, location)) const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) - const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps + const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ @@ -228,7 +209,8 @@ export const layer = Layer.effect( toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) - return yield* Effect.die(rebuildPreparedTurn()) + return yield* Effect.die(continueAfterCompaction(currentStep)) + const startSnapshot = yield* snapshots.capture() const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: agent.id, @@ -237,13 +219,12 @@ export const layer = Layer.effect( providerID: ProviderV2.ID.make(model.provider), ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), }, + snapshot: startSnapshot, }) const withPublication = Semaphore.makeUnsafe(1).withPermit const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => withPublication(publisher.publish(event, outputPaths)) let overflowFailure: ProviderErrorEvent | undefined - if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision))) - return yield* Effect.die(rebuildPreparedTurn()) const providerStream = llm.stream(request).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -300,19 +281,12 @@ export const layer = Layer.effect( isContextOverflowFailure(overflowFailure ?? failure) && (yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request }))) ) - return yield* Effect.die(continueAfterOverflowCompaction) + return yield* Effect.die(continueAfterOverflowCompaction(currentStep)) if (overflowFailure) yield* publish(overflowFailure) const llmFailure = failure instanceof LLMError ? failure : undefined if (llmFailure && !publisher.hasProviderError()) { yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) - yield* withPublication( - events.publish(SessionEvent.Step.Failed, { - sessionID: session.id, - timestamp: yield* DateTime.now, - assistantMessageID: yield* publisher.startAssistant(), - error: { type: "unknown", message: llmFailure.reason.message }, - }), - ) + yield* withPublication(publisher.failAssistant(llmFailure.reason.message)) } if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) @@ -327,19 +301,44 @@ export const layer = Layer.effect( ) { yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) + if (publisher.hasActiveAssistant()) + yield* withPublication(publisher.failAssistant("Provider turn interrupted")) } if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { const failure = Cause.squash(settled.cause) const message = failure instanceof Error ? failure.message : String(failure) yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) } + const stepSettlement = publisher.stepSettlement() + if (stepSettlement && !publisher.hasProviderError()) { + const endSnapshot = yield* snapshots.capture() + const files = + startSnapshot && endSnapshot + ? yield* snapshots + .files({ from: startSnapshot, to: endSnapshot }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + yield* withPublication( + events.publish(SessionEvent.Step.Ended, { + sessionID: session.id, + timestamp: yield* DateTime.now, + assistantMessageID: yield* publisher.startAssistant(), + finish: stepSettlement.finish, + cost: 0, + tokens: stepSettlement.tokens, + snapshot: endSnapshot, + files, + }), + ) + } if (publisher.hasProviderError()) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) if (stream._tag === "Success" && !publisher.hasProviderError()) yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) - if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause) - return !publisher.hasProviderError() && needsContinuation + if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)) + return yield* Effect.failCause(settled.cause) + return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep } }), ) }, Effect.scoped) @@ -347,7 +346,7 @@ export const layer = Layer.effect( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, - ) => Effect.Effect + ) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError> const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { return yield* runTurnAttempt(sessionID, promotion, step).pipe( @@ -357,7 +356,7 @@ export const layer = Layer.effect( if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") yield* Effect.yieldNow - return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step) + return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) }), ), ) @@ -370,8 +369,8 @@ export const layer = Layer.effect( if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") - return yield* runAfterOverflowCompaction(sessionID, undefined, step) - return yield* runTurn(sessionID, defect.transition.promotion, step) + return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) + return yield* runTurn(sessionID, undefined, defect.transition.step) }), ), ) @@ -379,23 +378,26 @@ export const layer = Layer.effect( const run = Effect.fn("SessionRunner.run")(function* (input: { readonly sessionID: SessionSchema.ID - readonly force?: boolean + readonly force: boolean }) { const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") - if (input.force !== true && !hasSteer && !hasQueue) return + if (!input.force && !hasSteer && !hasQueue) return yield* failInterruptedTools(input.sessionID) let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined - let openActivity = input.force === true || hasSteer || hasQueue - while (openActivity) { + let shouldRun = input.force || hasSteer || hasQueue + while (shouldRun) { let needsContinuation = true - for (let step = 1; needsContinuation; step++) { - needsContinuation = yield* runTurn(input.sessionID, promotion, step) + let step = 1 + while (needsContinuation) { + const result = yield* runTurn(input.sessionID, promotion, step) + needsContinuation = result.needsContinuation + step = result.step + 1 promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") } - openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") - promotion = openActivity ? "queue" : undefined + shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue") + promotion = shouldRun ? "queue" : undefined } }) @@ -405,4 +407,22 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + EventV2.node, + llmClient, + AgentV2.node, + ToolRegistry.node, + SessionRunnerModel.node, + SessionStore.node, + Location.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + Database.node, + ], +}) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index d067a7c6b93..2d907d39985 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -1,19 +1,17 @@ export * as SessionRunnerModel from "./model" +import { makeLocationNode } from "../../effect/app-node" import { type Model } from "@opencode-ai/llm" import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import { Auth, type AnyRoute } from "@opencode-ai/llm/route" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" import { Credential } from "../../credential" import { Integration } from "../../integration" -import { IntegrationConnection } from "../../integration/connection" import { ModelV2 } from "../../model" -import { ModelRequest } from "../../model-request" -import { PluginBoot } from "../../plugin/boot" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" @@ -22,7 +20,36 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.ModelUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + }, +) { + override get message() { + return `Model unavailable: ${this.providerID}/${this.modelID}` + } +} + +export class VariantUnavailableError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.VariantUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + variant: ModelV2.VariantID, + }, +) { + override get message() { + return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}` + } +} export class UnsupportedApiError extends Schema.TaggedErrorClass()( "SessionRunnerModel.UnsupportedApiError", @@ -31,13 +58,18 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass Effect.Effect @@ -48,17 +80,14 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => { - if (credential?.value.type === "key") return Auth.value(credential.value.key) - if (credential?.value.type === "oauth") return Auth.value(credential.value.access) +const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { + if (credential?.type === "key") return Auth.value(credential.key) + if (credential?.type === "oauth") return Auth.value(credential.access) const value = model.request.body.apiKey ?? model.api.settings?.apiKey if (typeof value === "string") return Auth.value(value) - return connection?.type === "env" ? Auth.config(connection.name) : undefined } const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { - const options = model.request.options ?? {} - const namespace = model.api.type === "aisdk" ? ModelRequest.namespace(model.api.package) : undefined const body = model.request.body const httpBody = Object.hasOwn(body, "apiKey") ? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey")) @@ -67,20 +96,33 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { provider: model.providerID, endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url }, headers: model.request.headers, - generation: model.request.generation, - providerOptions: namespace && Object.keys(options).length > 0 ? { [namespace]: options } : undefined, http: { body: httpBody }, limits: { context: model.limit.context, output: model.limit.output }, }) } -const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => { +const withVariant = ( + model: ModelV2.Info, + variantID: ModelV2.VariantID | undefined, +): Effect.Effect => { const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID const variant = model.variants.find((item) => item.id === id) - if (!variant) return model - return produce(model, (draft) => { - ModelRequest.assign(draft.request, variant) - }) + if (!variant && variantID !== undefined && variantID !== "default") + return Effect.fail( + new VariantUnavailableError({ + providerID: model.providerID, + modelID: model.id, + variant: variantID, + }), + ) + return Effect.succeed( + variant + ? produce(model, (draft) => { + Object.assign(draft.request.headers, variant.headers) + Object.assign(draft.request.body, variant.body) + }) + : model, + ) } const apiName = (model: ModelV2.Info) => @@ -88,22 +130,25 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, - connection?: IntegrationConnection.Info, - credential?: Credential.Stored, + credential?: Credential.Value, ): Effect.Effect => { - const resolved = - credential?.value.metadata === undefined + const merged = + credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { - Object.assign(draft.request.body, credential.value.metadata) - // kilocode_change start - Kilo Gateway consumes the migrated OAuth account as its organization route - if (credential.value.type === "oauth" && credential.value.metadata?.accountID) { - draft.request.body.kilocodeOrganizationId = credential.value.metadata.accountID - delete draft.request.body.accountID - } - // kilocode_change end + Object.assign(draft.request.body, credential.metadata) }) - const key = apiKey(resolved, connection, credential) + // kilocode_change start - Kilo Gateway consumes the migrated OAuth account as its organization route. + // Only the account id crosses into the request body; other oauth metadata stays out of provider requests. + const resolved = + credential?.type === "oauth" && credential.metadata?.accountID !== undefined + ? produce(merged, (draft) => { + draft.request.body.kilocodeOrganizationId = String(credential.metadata!.accountID) + delete draft.request.body.accountID + }) + : merged + // kilocode_change end + const key = apiKey(resolved, credential) if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { return Effect.succeed( withDefaults(resolved, OpenAIResponses.route) @@ -134,8 +179,8 @@ export const fromCatalogModel = ( ) } -export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) => - fromCatalogModel(withVariant(model, session.model?.variant)) +export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) => + withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential))) export const supported = (model: ModelV2.Info) => model.api.type === "aisdk" && @@ -148,25 +193,36 @@ export const locationLayer = Layer.effect( Service, Effect.gen(function* () { const catalog = yield* Catalog.Service - const credentials = yield* Credential.Service const integrations = yield* Integration.Service - const boot = yield* PluginBoot.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. - yield* boot.wait() + const defaultModel = session.model ? undefined : yield* catalog.model.default() const selected = session.model - ? yield* catalog.model.get(session.model.providerID, session.model.id) - : (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ?? - (yield* catalog.model.available()).find(supported)) + ? (yield* catalog.model.available()).find( + (model) => model.providerID === session.model?.providerID && model.id === session.model.id, + ) + : defaultModel && supported(defaultModel) + ? defaultModel + : (yield* catalog.model.available()).find(supported) + if (!selected && session.model) + return yield* new ModelUnavailableError({ + providerID: session.model.providerID, + modelID: session.model.id, + }) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) - const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) - return yield* fromCatalogModel( - withVariant(selected, session.model?.variant), - connection, - connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined, + const provider = yield* catalog.provider.get(selected.providerID) + const connection = yield* integrations.connection.active( + provider?.integrationID ?? Integration.ID.make(selected.providerID), + ) + return yield* resolve( + session, + selected, + connection ? yield* integrations.connection.resolve(connection) : undefined, ) }), }) }), ) + +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Catalog.node, Integration.node] }) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 5390a26e3b8..33652a618c9 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -10,6 +10,7 @@ type Input = { readonly sessionID: SessionSchema.ID readonly agent: string readonly model: ModelV2.Ref + readonly snapshot?: string } const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0) @@ -65,15 +66,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) >() const timestamp = DateTime.now let assistantMessageID: SessionMessage.ID | undefined + let assistantActive = false + let assistantFailed = false let providerFailed = false + let stepSettlement: { readonly finish: string; readonly tokens: ReturnType } | undefined const startAssistant = Effect.fnUntraced(function* () { if (assistantMessageID !== undefined) return assistantMessageID assistantMessageID = SessionMessage.ID.create() + assistantActive = true yield* events.publish(SessionEvent.Step.Started, { ...input, assistantMessageID, timestamp: yield* timestamp, + snapshot: input.snapshot, }) return assistantMessageID }) @@ -190,6 +196,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* flushFragments() }) + const failAssistant = Effect.fnUntraced(function* (message: string) { + if (assistantFailed) return + yield* flush() + const assistantMessageID = yield* startAssistant() + assistantActive = false + assistantFailed = true + yield* events.publish(SessionEvent.Step.Failed, { + sessionID: input.sessionID, + timestamp: yield* timestamp, + assistantMessageID, + error: { type: "unknown", message }, + }) + }) + const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( message: string, hostedOnly = false, @@ -375,26 +395,15 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } case "step-finish": yield* flush() - yield* events.publish(SessionEvent.Step.Ended, { - sessionID: input.sessionID, - timestamp: yield* timestamp, - assistantMessageID: yield* startAssistant(), - finish: event.reason, - cost: 0, - tokens: tokens(event.usage), - }) + assistantActive = false + if (stepSettlement) return yield* Effect.die("Duplicate step finish") + stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } return case "finish": return case "provider-error": providerFailed = true - yield* flush() - yield* events.publish(SessionEvent.Step.Failed, { - sessionID: input.sessionID, - timestamp: yield* timestamp, - assistantMessageID: yield* startAssistant(), - error: { type: "unknown", message: event.message }, - }) + yield* failAssistant(event.message) return } }) @@ -402,9 +411,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return { publish, flush, + failAssistant, failUnsettledTools, + hasActiveAssistant: () => assistantActive, hasAssistantStarted: () => assistantMessageID !== undefined, hasProviderError: () => providerFailed, + stepSettlement: () => stepSettlement, startAssistant, assistantMessageID: assistantMessageIDForTool, } diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index ae36f205b17..b2b1af5d30f 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -70,24 +70,46 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid const assistant = (message: SessionMessage.Assistant, model: Model) => { const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) + const reuseProviderMetadata = sameModel && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") return sameModel - ? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }] + ? [ + { + type: "reasoning", + text: item.text, + providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, + }, + ] : item.text.length > 0 ? [{ type: "text", text: item.text }] : [] - const call = toolCall(item, sameModel ? item.provider?.metadata : undefined) - const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined) - return item.provider?.executed === true && result ? [call, result] : [call] + const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) + if (item.provider?.executed !== true) return [call] + const result = toolResult( + item, + reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined, + ) + return result ? [call, result] : [call] + }) + const meaningful = content.filter((part) => { + if (part.type === "text") return part.text !== "" + if (part.type !== "reasoning") return true + return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0) }) const results = message.content .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) - .map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)) + .map((item) => + toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined), + ) .filter((message) => message !== undefined) .map(Message.tool) - return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results] + if (meaningful.length === 0) return results + return [ + Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }), + ...results, + ] } function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { diff --git a/packages/core/src/session/schema.ts b/packages/core/src/session/schema.ts index 8509cabee41..05808a27992 100644 --- a/packages/core/src/session/schema.ts +++ b/packages/core/src/session/schema.ts @@ -1,49 +1,9 @@ export * as SessionSchema from "./schema" -import { Schema } from "effect" -import { Location } from "../location" -import { ModelV2 } from "../model" -import { ProjectV2 } from "../project" -import { externalID, type ExternalID, RelativePath, optionalOmitUndefined, withStatics } from "../schema" -import { Identifier } from "../util/identifier" -import { V2Schema } from "../v2-schema" -import { AgentV2 } from "../agent" +import { Session } from "@opencode-ai/schema/session" -export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( - Schema.brand("SessionID"), - withStatics((schema) => { - const create = () => schema.make("ses_" + Identifier.descending()) - return { - create, - descending: (id?: string) => (id === undefined ? create() : schema.make(id)), - fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)), - } - }), -) +export const ID = Session.ID export type ID = typeof ID.Type -export class Info extends Schema.Class("SessionV2.Info")({ - id: ID, - parentID: ID.pipe(optionalOmitUndefined), - projectID: ProjectV2.ID, - agent: AgentV2.ID.pipe(Schema.optional), - model: ModelV2.Ref.pipe(Schema.optional), - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - updated: V2Schema.DateTimeUtcFromMillis, - archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), - }), - title: Schema.String, - location: Location.Ref, - subpath: RelativePath.pipe(Schema.optional), -}) {} +export const Info = Session.Info +export type Info = Session.Info diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 9ca1748bd48..03361c3b29e 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -12,7 +12,7 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" import type { SystemContext } from "../system-context/index" -import { AgentV2 } from "../agent" +import type { Revert } from "@opencode-ai/schema/revert" import { RecallPartIndex } from "../kilocode/session/recall-part-index" // kilocode_change type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> @@ -38,7 +38,7 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - summary_diffs: text({ mode: "json" }).$type(), + summary_diffs: text({ mode: "json" }).$type(), metadata: text({ mode: "json" }).$type>(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), @@ -46,7 +46,10 @@ export const SessionTable = sqliteTable( tokens_reasoning: integer().notNull().default(0), tokens_cache_read: integer().notNull().default(0), tokens_cache_write: integer().notNull().default(0), - revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), + // kilocode_change - Kilo also persists a workspace restore status on the revert record + revert: text({ mode: "json" }).$type< + Revert.State & { workspace?: "restored" | "snapshots-disabled" | "unavailable" } + >(), permission: text({ mode: "json" }).$type(), agent: text(), model: text({ mode: "json" }).$type<{ @@ -172,9 +175,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", { .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), baseline: text().notNull(), - agent: text().$type().notNull().default(AgentV2.defaultID), snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), - replacement_seq: integer(), - revision: integer().notNull().default(0), }) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index e327f7660e0..5f692ff302a 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -3,6 +3,7 @@ export * as SessionStore from "./store" import { and, eq, isNotNull } from "drizzle-orm" // kilocode_change import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/app-node" import { SessionHistory } from "./history" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" @@ -25,7 +26,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionStore") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -60,4 +61,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/session/todo.ts b/packages/core/src/session/todo.ts index 7b3c3be3f69..56c73042c4a 100644 --- a/packages/core/src/session/todo.ts +++ b/packages/core/src/session/todo.ts @@ -1,30 +1,17 @@ export * as SessionTodo from "./todo" import { asc, eq } from "drizzle-orm" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer } from "effect" +import { SessionTodo } from "@opencode-ai/schema/session-todo" import { Database } from "../database/database" +import { makeLocationNode } from "../effect/app-node" import { EventV2 } from "../event" import { SessionSchema } from "./schema" import { TodoTable } from "./sql" -export const Info = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}).annotate({ identifier: "SessionTodo.Info" }) +export const Info = SessionTodo.Info export type Info = typeof Info.Type - -export const Event = { - Updated: EventV2.define({ - type: "todo.updated", - schema: { - sessionID: SessionSchema.ID, - todos: Schema.Array(Info), - }, - }), -} +export const Event = SessionTodo.Event export interface Interface { readonly update: (input: { @@ -36,7 +23,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionTodo") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -88,4 +75,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 259c8aff5e5..be1cd1d49ad 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -1,58 +1,31 @@ export * as SkillV2 from "./skill" +import { makeLocationNode } from "./effect/app-node" import path from "path" -import { Context, Effect, Layer, Schema } from "effect" -import { castDraft } from "immer" +import { Context, Effect, Layer, Schema, Types } from "effect" +import { Skill } from "@opencode-ai/schema/skill" import { AgentV2 } from "./agent" import { ConfigMarkdown } from "./config/markdown" import { FSUtil } from "./fs-util" import { PermissionV2 } from "./permission" -import { AbsolutePath, withStatics } from "./schema" +import { AbsolutePath } from "./schema" import { SkillDiscovery } from "./skill/discovery" import { State } from "./state" -export class DirectorySource extends Schema.Class("SkillV2.DirectorySource")({ - type: Schema.Literal("directory"), - path: AbsolutePath, -}) {} +export const DirectorySource = Skill.DirectorySource +export type DirectorySource = Skill.DirectorySource -export class UrlSource extends Schema.Class("SkillV2.UrlSource")({ - type: Schema.Literal("url"), - url: Schema.String, -}) {} +export const UrlSource = Skill.UrlSource +export type UrlSource = Skill.UrlSource -export class EmbeddedSource extends Schema.Class("SkillV2.EmbeddedSource")({ - type: Schema.Literal("embedded"), - skill: Schema.suspend(() => Info), -}) {} +export const EmbeddedSource = Skill.EmbeddedSource +export type EmbeddedSource = Skill.EmbeddedSource -export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe( - Schema.toTaggedUnion("type"), - withStatics(() => ({ - equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => { - if (a.type !== b.type) return false - if (a.type === "directory" && b.type === "directory") return a.path === b.path - if (a.type === "url" && b.type === "url") return a.url === b.url - if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name - return false - }, - key: (source: DirectorySource | UrlSource | EmbeddedSource) => - source.type === "directory" - ? `directory:${source.path}` - : source.type === "url" - ? `url:${source.url}` - : `embedded:${source.skill.name}`, - })), -) +export const Source = Skill.Source export type Source = typeof Source.Type -export class Info extends Schema.Class("SkillV2.Info")({ - name: Schema.String, - description: Schema.String.pipe(Schema.optional), - slash: Schema.Boolean.pipe(Schema.optional), - location: AbsolutePath, - content: Schema.String, -}) {} +export const Info = Skill.Info +export type Info = Skill.Info export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") @@ -65,34 +38,33 @@ const Frontmatter = Schema.Struct({ const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) export type Data = { - sources: Source[] + sources: Types.DeepMutable[] } -export type Editor = { +export type Draft = { source: (source: Source) => void list: () => readonly Source[] } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly sources: () => Effect.Effect readonly list: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Skill") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const discovery = yield* SkillDiscovery.Service const fs = yield* FSUtil.Service - const state = State.create({ + const state = State.create({ initial: () => ({ sources: [] }), - editor: (draft) => ({ + draft: (draft) => ({ source: (source) => { if (draft.sources.some((item) => Source.equals(item, source))) return - draft.sources.push(castDraft(source)) + draft.sources.push(source as Types.DeepMutable) }, list: () => draft.sources as Source[], }), @@ -120,15 +92,13 @@ export const layer = Layer.effect( ? path.basename(filepath, ".md") : undefined if (!name) continue - skills.push( - new Info({ - name, - description: frontmatter.description, - slash: frontmatter.slash, - location: AbsolutePath.make(filepath), - content: markdown.content, - }), - ) + skills.push({ + name, + description: frontmatter.description, + slash: frontmatter.slash, + location: AbsolutePath.make(filepath), + content: markdown.content, + }) } } return skills @@ -150,6 +120,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, + reload: state.reload, sources: Effect.fn("SkillV2.sources")(function* () { return state.get().sources }), @@ -158,4 +129,4 @@ export const layer = Layer.effect( }), ) -export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer)) +export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node] }) diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index 6402dd3b71a..a192ba1023b 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -5,6 +5,8 @@ import { Context, Effect, Layer, Schedule, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FSUtil } from "../fs-util" import { Global } from "../global" +import { makeGlobalNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" import { AbsolutePath } from "../schema" const skillConcurrency = 4 @@ -52,6 +54,7 @@ function isSafeRelativePath(value: string) { class IndexSkill extends Schema.Class("SkillDiscovery.IndexSkill")({ name: Schema.String, + version: Schema.optional(Schema.String), files: Schema.Array(Schema.String), }) {} @@ -65,7 +68,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SkillDiscovery") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -80,12 +83,15 @@ export const layer = Layer.effect( ) const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) { - if (yield* fs.exists(destination).pipe(Effect.orDie)) return - yield* HttpClientRequest.get(url).pipe( + if (yield* fs.exists(destination).pipe(Effect.orDie)) return true + return yield* HttpClientRequest.get(url).pipe( http.execute, Effect.flatMap((response) => response.arrayBuffer), Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))), - Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })), + Effect.as(true), + Effect.catch((error) => + Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)), + ), ) }) @@ -120,6 +126,7 @@ export const layer = Layer.effect( } const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source) + const versionFile = path.join(root, ".opencode-version") const files = skill.files.map((file) => { if (!isSafeRelativePath(file)) return undefined let resource: URL @@ -135,23 +142,66 @@ export const layer = Layer.effect( return { url: resource.href, destination, + file, } }) if (files.some((file) => file === undefined)) { return [] } - return [{ skill, root, files: files as { url: string; destination: string }[] }] + return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }] }), - ({ skill, root, files }) => + ({ skill, root, versionFile, files }) => Effect.gen(function* () { - yield* Effect.forEach(files, (file) => download(file.url, file.destination), { - concurrency: fileConcurrency, - discard: true, - }) - return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || + const version = skill.version + const current = + version === undefined + ? undefined + : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (version === undefined || current === version) { + yield* Effect.forEach(files, (file) => download(file.url, file.destination), { + concurrency: fileConcurrency, + discard: true, + }) + } else { + const token = crypto.randomUUID() + const staging = `${root}.tmp-${token}` + const backup = `${root}.old-${token}` + yield* Effect.gen(function* () { + const downloaded = yield* Effect.forEach( + files, + (file) => download(file.url, path.resolve(staging, file.file)), + { concurrency: fileConcurrency }, + ) + if (!downloaded.every(Boolean)) return + const exists = + (yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) || + (yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie)) + if (!exists) return + yield* fs.writeFileString(path.join(staging, ".opencode-version"), version) + yield* Effect.uninterruptible( + Effect.gen(function* () { + const cached = yield* fs.exists(root).pipe(Effect.orDie) + if (cached) yield* fs.rename(root, backup) + yield* fs.rename(staging, root).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore) + return yield* Effect.fail(error) + }), + ), + ) + if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore) + }), + ) + }).pipe( + Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })), + Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), + ) + } + const exists = + (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || (yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie)) - ? [AbsolutePath.make(root)] - : [] + return exists ? [AbsolutePath.make(root)] : [] }), { concurrency: skillConcurrency }, ).pipe(Effect.map((directories) => directories.flat())) @@ -160,8 +210,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.defaultLayer), -) +export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, FSUtil.node, Global.node] }) diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 92fb4c0a629..347179dbfbf 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -1,9 +1,9 @@ export * as SkillGuidance from "./guidance" +import { makeLocationNode } from "../effect/app-node" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" -import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" import { SystemContext } from "../system-context/index" @@ -37,15 +37,13 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SkillGuidance") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service return Service.of({ load: Effect.fn("SkillGuidance.load")(function* (selection) { - yield* boot.wait() const agent = selection.info if (!agent) return SystemContext.empty const permitted = SkillV2.available(yield* skills.list(), agent) @@ -74,3 +72,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [SkillV2.node] }) diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index b39c0f7f014..de5962b4837 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -1,9 +1,266 @@ -export namespace Snapshot { - export type FileDiff = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - } +export * as Snapshot from "./snapshot" + +import { makeLocationNode } from "./effect/app-node" +import path from "path" +import { Context, Effect, Layer, Schema } from "effect" +import { Config } from "./config" +import { File } from "./file" +import { FSUtil } from "./fs-util" +import { Git } from "./git" +import { Global } from "./global" +import { Location } from "./location" +import { AbsolutePath, RelativePath } from "./schema" +import { Hash } from "./util/hash" + +export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) +export type ID = typeof ID.Type + +export class Error extends Schema.TaggedErrorClass()("Snapshot.Error", { + operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]), + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export interface CompareInput { + readonly from: ID + readonly to: ID +} + +export interface DiffInput extends CompareInput { + readonly context?: number + readonly paths?: readonly RelativePath[] +} + +export interface RestoreInput { + /** Paths are relative to the project root. */ + readonly files: ReadonlyMap +} + +export interface PreviewInput extends RestoreInput { + readonly context?: number +} + +export interface Interface { + /** + * Capture the current Location-scoped filesystem state as a content-addressed + * tree. Returns `undefined` when snapshots are disabled, unsupported, or the + * best-effort capture fails. + */ + readonly capture: () => Effect.Effect + + /** + * List project-relative paths changed between two captured trees without + * loading file contents or generating patches. + */ + readonly files: (input: CompareInput) => Effect.Effect + + /** + * Generate structured per-file diffs between two captured trees. `context` + * controls unchanged lines around each unified diff hunk. + */ + readonly diff: (input: DiffInput) => Effect.Effect + + /** + * Preview the filesystem result of a selective restore without modifying the + * worktree. Each project-relative path maps to the tree it would be restored + * from. + */ + readonly preview: (input: PreviewInput) => Effect.Effect + + /** + * Restore selected project-relative paths from their associated trees. A path + * absent from its selected tree is removed; paths outside the map are untouched. + */ + readonly restore: (input: RestoreInput) => Effect.Effect + + /** + * Replace the snapshot index with a captured tree and check out all its entries. + * Files absent from the tree remain untouched. Prefer selective `restore` when + * only known paths should change. + */ + readonly checkout: (snapshot: ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Snapshot") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const git = yield* Git.Service + const global = yield* Global.Service + const location = yield* Location.Service + const source = yield* git.repo.discover(location.project.directory) + const worktree = source + ? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie)) + : location.project.directory + const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree))) + + const scope = Effect.fnUntraced(function* () { + const relative = path.relative(worktree, location.directory) + if (relative.startsWith("..") || path.isAbsolute(relative)) + return yield* new Error({ operation: "capture", message: "Location is outside the project" }) + return RelativePath.make(relative.replaceAll("\\", "/") || ".") + }) + + const repository = Effect.fnUntraced(function* () { + if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" }) + if (yield* fs.existsSafe(path.join(gitDirectory, "HEAD"))) + return new Git.Repository({ + worktree, + gitDirectory, + commonDirectory: gitDirectory, + }) + return yield* git.repo + .create({ + worktree, + gitDirectory, + seed: source, + }) + .pipe(Effect.mapError((cause) => failure("capture", cause))) + }) + + const enabled = Effect.fnUntraced(function* () { + if (location.vcs?.type !== "git") return false + return Config.latest(yield* config.entries(), "snapshots") !== false + }) + + const capture = Effect.fn("Snapshot.capture")(function* () { + if (!(yield* enabled())) return undefined + return yield* Effect.gen(function* () { + const repo = yield* repository() + return ID.make( + yield* git.tree.capture({ + repository: repo, + scopes: [yield* scope()], + ignores: source, + maximumUntrackedFileBytes: 2 * 1024 * 1024, + }), + ) + }).pipe( + Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))), + ) + }) + + const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) { + const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause))) + return { repository: repo, from: Git.TreeID.make(input.from), to: Git.TreeID.make(input.to) } + }) + + const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) { + const comparison = yield* compare("files", input) + const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("files", cause))) + if (!source) return files + const ignored = yield* git.index + .ignored({ repository: source, paths: files }) + .pipe(Effect.mapError((cause) => failure("files", cause))) + return files.filter((file) => !ignored.has(file)) + }) + + const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) { + const comparison = yield* compare("diff", input) + const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("diff", cause))) + const ignored = source + ? yield* git.index + .ignored({ repository: source, paths: files }) + .pipe(Effect.mapError((cause) => failure("diff", cause))) + : new Set() + return yield* git.tree + .diff({ + ...comparison, + context: input.context, + paths: (input.paths ?? files).filter((file) => !ignored.has(file)), + }) + .pipe(Effect.mapError((cause) => failure("diff", cause))) + }) + + const plan = Effect.fnUntraced(function* (operation: "preview" | "restore", input: RestoreInput) { + const files = new Map() + for (const [file, snapshot] of input.files) { + const absolute = path.resolve(worktree, file) + if (!FSUtil.contains(worktree, absolute)) + return yield* new Error({ operation, message: `Path escapes the project: ${file}` }) + files.set(file, Git.TreeID.make(snapshot)) + } + return files + }) + + const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) { + if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" }) + const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause))) + const files = yield* plan("preview", input) + const current = yield* git.tree + .capture({ + repository: repo, + scopes: Array.from(files.keys()), + ignores: source, + maximumUntrackedFileBytes: 2 * 1024 * 1024, + }) + .pipe(Effect.mapError((cause) => failure("preview", cause))) + return yield* git.tree + .preview({ + repository: repo, + current, + files, + context: input.context, + }) + .pipe(Effect.mapError((cause) => failure("preview", cause))) + }) + + const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) { + if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" }) + const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + yield* git.tree + .restore({ repository: repo, files: yield* plan("restore", input) }) + .pipe(Effect.mapError((cause) => failure("restore", cause))) + }) + + const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) { + const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause))) + yield* git.tree + .checkout({ repository: repo, tree: Git.TreeID.make(snapshot) }) + .pipe(Effect.mapError((cause) => failure("restore", cause))) + }) + + return Service.of({ capture, files, diff, preview, restore, checkout }) + }), +) + +export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node], +}) + +export const noopLayer = Layer.succeed( + Service, + Service.of({ + capture: () => Effect.succeed(undefined), + files: () => Effect.succeed([]), + diff: () => Effect.succeed([]), + preview: () => Effect.succeed([]), + restore: () => Effect.void, + checkout: () => Effect.void, + }), +) + +function failure(operation: Error["operation"], cause: unknown) { + if (cause instanceof Error && cause.operation === operation) return cause + return new Error({ + operation, + message: cause instanceof globalThis.Error ? cause.message : String(cause), + cause, + }) +} + +/** Legacy persisted session diff shape. */ +export type LegacyFileDiff = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" } diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 7f1ae58d24c..e825b581880 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -1,112 +1,145 @@ export * as State from "./state" -import { Effect, Scope, Semaphore } from "effect" -import type { Draft, Objectish } from "immer" +import { Context, Effect, Scope, Semaphore } from "effect" /** - * A replayable transform applied to an editor during rebuild. + * A replayable transform applied to a draft during reload. * - * Transforms are intentionally synchronous and mutation-shaped: domain editors - * hide the draft representation while preserving concise plugin/config code. + * Domain drafts expose readable and writable state while preserving concise + * plugin/config code. Transforms may perform Effects before returning. */ -export type Transform = (editor: Editor) => void -export type MakeEditor = (draft: Draft) => Editor +type TransformCallback = (draft: DraftApi) => Effect.Effect | void +export type MakeDraft = (state: State) => DraftApi -export interface Options { - /** Creates the base value for initial state and every scoped-transform rebuild. */ - readonly initial: () => State - /** Wraps the mutable draft in a domain-specific editor. */ - readonly editor: MakeEditor - /** - * Completes every committed edit. - * - * For rebuilds, this runs after all active transforms have been replayed and - * before the rebuilt state becomes visible. For direct updates, this runs - * after the current state has already been edited. The optional reason is - * caller-defined metadata for exceptional update origins. - */ - readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect +export interface Registration { + readonly dispose: Effect.Effect } -export interface Interface { +export type Transform = ( + transform: TransformCallback, +) => Effect.Effect + +export type Reload = () => Effect.Effect + +export interface Transformable { + readonly transform: Transform + readonly reload: Reload +} + +const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { + defaultValue: () => undefined, +}) + +export function batch(effect: Effect.Effect) { + return Effect.gen(function* () { + const current = yield* CurrentBatch + if (current) return yield* effect + const reloads = new Set() + const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads)) + yield* Effect.forEach(reloads, (reload) => reload(), { discard: true }) + return result + }) +} + +export interface Options { + /** Creates the base value for initial state and every scoped-transform reload. */ + readonly initial: () => State + /** Wraps mutable state in a domain-specific draft API. */ + readonly draft: MakeDraft + /** Runs after all active transforms and before the rebuilt state becomes visible. */ + readonly finalize?: (draft: DraftApi) => Effect.Effect +} + +export interface Interface extends Transformable { readonly get: () => State /** - * Registers a scoped transform slot and returns the slot updater. - * - * Acquiring the slot has no visible effect until the returned updater is - * called. Each updater call replaces that slot's transform, then rebuilds the - * materialized state from `initial()` by replaying all active transforms in - * registration order. Closing the owning Scope removes the slot and rebuilds. + * Registers and applies a scoped transform. Closing the owning Scope removes + * the transform and reloads the materialized state. */ - readonly transform: () => Effect.Effect<(transform: Transform) => Effect.Effect, never, Scope.Scope> - /** Registers and applies a replayable transform in the current Scope. */ - readonly update: (update: Transform) => Effect.Effect + // kilocode_change start - Kilo reconciles config-derived state outside the transform fold /** * Mutates the current materialized state directly, once. * - * This is not replayable transform state: a later rebuild starts again - * from `initial()` plus active transforms, so direct edits must be reserved - * for current-state adjustments that are intentionally outside the transform - * fold. + * This is not replayable transform state: a later reload starts again from + * `initial()` plus active transforms, so direct edits must be reserved for + * current-state adjustments that are intentionally outside the fold. */ - readonly mutate: (update: (editor: Editor) => Effect.Effect, reason?: string) => Effect.Effect + readonly mutate: (update: (draft: DraftApi) => Effect.Effect) => Effect.Effect + // kilocode_change end } -export function create(options: Options): Interface { +export function create(options: Options): Interface { let state = options.initial() - let transforms: { update: Transform }[] = [] + let transforms: { run: TransformCallback }[] = [] const semaphore = Semaphore.makeUnsafe(1) - const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) { - const api = options.editor(next as Draft) - if (options.finalize) yield* options.finalize(api, reason) + const commit = Effect.fn("State.commit")(function* (next: State) { + const api = options.draft(next) + if (options.finalize) yield* options.finalize(api) state = next }) - const rebuild = Effect.fnUntraced(function* () { + const apply = (transform: TransformCallback, draft: DraftApi) => + Effect.suspend(() => { + const result = transform(draft) + return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void + }) + + const materialize = Effect.fnUntraced(function* () { const next = options.initial() - const api = options.editor(next as Draft) - for (const transform of transforms) - yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {})) + const api = options.draft(next) + for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.reload.update")) yield* commit(next) }) - const result: Interface = { + const reload = () => semaphore.withPermit(materialize()) + + const result: Interface = { get: () => state, - transform: Effect.fn("State.transform")(function* () { + transform: Effect.fn("State.transform")(function* (update) { const scope = yield* Scope.Scope return yield* Effect.uninterruptible( Effect.gen(function* () { - const transform = { update: (_editor: Editor) => {} } - transforms = [...transforms, transform] - yield* Scope.addFinalizer( - scope, + const transform = { run: update } + let active = true + const dispose = Effect.uninterruptible( semaphore.withPermit( - Effect.sync(() => { + Effect.suspend(() => { + if (!active) return Effect.void + active = false transforms = transforms.filter((item) => item !== transform) - }).pipe(Effect.andThen(rebuild())), + return Effect.gen(function* () { + const batch = yield* CurrentBatch + if (batch) { + batch.add(reload) + return + } + yield* materialize() + }) + }), ), ) - return (update: Transform) => - Effect.uninterruptible( - semaphore.withPermit( - Effect.sync(() => { - transform.update = update - }).pipe(Effect.andThen(rebuild())), - ), - ) + yield* semaphore.withPermit( + Effect.sync(() => { + transforms = [...transforms, transform] + }), + ) + yield* Scope.addFinalizer(scope, dispose) + const batch = yield* CurrentBatch + if (batch) batch.add(reload) + else yield* reload() + return { dispose } }), ) }), - update: Effect.fn("State.update")(function* (update) { - const transform = yield* result.transform() - yield* transform(update) - }), - mutate: Effect.fn("State.mutate")(function* (update, reason) { - const api = options.editor(state as Draft) + reload, + // kilocode_change start + mutate: Effect.fn("State.mutate")(function* (update) { + const api = options.draft(state) yield* update(api) - if (options.finalize) yield* options.finalize(api, reason) + if (options.finalize) yield* options.finalize(api) }, semaphore.withPermit), + // kilocode_change end } return result } diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index 42cba27b922..b8b50577ccc 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -1,10 +1,13 @@ export * as SystemContextBuiltIns from "./builtins" +import { makeLocationNode } from "../effect/app-node" import { DateTime, Effect, Layer, Schema } from "effect" import { Location } from "../location" import { SystemContext } from "./index" import { InstructionContext } from "../instruction-context" import { SystemContextRegistry } from "./registry" +import { FSUtil } from "../fs-util" +import { Global } from "../global" const builtIns = Layer.effectDiscard( Effect.gen(function* () { @@ -40,8 +43,8 @@ const builtIns = Layer.effectDiscard( }), ) -export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe( - Layer.provideMerge(SystemContextRegistry.layer), -) - -export const locationLayer = layer +export const node = makeLocationNode({ + name: "system-context-builtins", + layer: builtIns, + deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node], +}) diff --git a/packages/core/src/system-context/index.ts b/packages/core/src/system-context/index.ts index 9fd4ca119f5..c0a583c08c3 100644 --- a/packages/core/src/system-context/index.ts +++ b/packages/core/src/system-context/index.ts @@ -82,7 +82,11 @@ export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | Replace export class InitializationBlocked extends Schema.TaggedErrorClass()( "SystemContext.InitializationBlocked", { keys: Schema.Array(Key) }, -) {} +) { + override get message() { + return `System context initialization blocked by unavailable sources: ${this.keys.join(", ")}` + } +} export class DuplicateKeyError extends Schema.TaggedErrorClass()("SystemContext.DuplicateKeyError", { key: Key, diff --git a/packages/core/src/system-context/registry.ts b/packages/core/src/system-context/registry.ts index a2a7ca7e629..c1e7ca5e858 100644 --- a/packages/core/src/system-context/registry.ts +++ b/packages/core/src/system-context/registry.ts @@ -2,6 +2,7 @@ export * as SystemContextRegistry from "./registry" import { Context, Effect, Layer, Ref, Scope } from "effect" import { SystemContext } from "./index" +import { makeLocationNode } from "../effect/app-node" export interface Entry { readonly key: SystemContext.Key @@ -15,7 +16,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SystemContextRegistry") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const entries = yield* Ref.make>([]) @@ -44,3 +45,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 2d15ee8d0dc..1030ff22ffc 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -5,6 +5,7 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe import { Config } from "./config" import { FSUtil } from "./fs-util" import { Global } from "./global" +import { makeGlobalNode, makeLocationNode } from "./effect/app-node" import { SessionSchema } from "./session/schema" import { Identifier } from "./util/identifier" import type { ToolOutput } from "@opencode-ai/llm" @@ -28,8 +29,13 @@ export interface BoundResult { export class StorageError extends Schema.TaggedErrorClass()("ToolOutputStore.StorageError", { operation: Schema.Literals(["encode", "write"]), - cause: Schema.Defect, -}) {} + cause: Schema.Defect(), +}) { + override get message() { + const detail = this.cause instanceof Error ? this.cause.message : String(this.cause) + return `Failed to ${this.operation} tool output${detail ? `: ${detail}` : ""}` + } +} export type Error = StorageError @@ -103,7 +109,7 @@ const lineCount = (text: string) => { return count } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -186,7 +192,9 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer)) +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] }) + +export const nodeWithoutConfig = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node] }) /** Runs retention scanning once globally rather than once per active Location. */ export const cleanupLayer = Layer.effectDiscard( @@ -196,4 +204,8 @@ export const cleanupLayer = Layer.effectDiscard( }), ) -export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer))) +export const cleanupNode = makeGlobalNode({ + name: "tool-output-cleanup", + layer: Layer.merge(layer, cleanupLayer.pipe(Layer.provide(layer))), + deps: [FSUtil.node, Global.node], +}) diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 024c2006d04..4d541691b29 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -1,15 +1,15 @@ export * as ApplicationTools from "./application-tools" import { Context, Effect, Layer, Scope } from "effect" -import { enableMapSet } from "immer" import { State } from "../state" import { Tool } from "./tool" +import { makeGlobalNode } from "../effect/app-node" type Data = { readonly entries: Map } -type Editor = { +type Draft = { readonly set: (name: string, entry: Entry) => void } @@ -27,14 +27,12 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ApplicationTools") {} -enableMapSet() - -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const state = State.create({ + const state = State.create({ initial: () => ({ entries: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ set: (name, tool) => { draft.entries.set(name, tool) }, @@ -47,12 +45,13 @@ export const layer = Layer.effect( if (entries.length === 0) return yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const) - const transform = yield* state.transform() - yield* transform((editor) => { - for (const [name, entry] of registrations) editor.set(name, entry) + yield* state.transform((draft) => { + for (const [name, entry] of registrations) draft.set(name, entry) }) }), entries: () => state.get().entries, }) }), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 78e5b0f40d5..60747408155 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -1,12 +1,17 @@ export * as ApplyPatchTool from "./apply-patch" 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 { ToolOutputStore } from "../tool-output-store" // kilocode_change +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -24,9 +29,27 @@ export const Applied = Schema.Struct({ target: Schema.String, }) -export const Output = Schema.Struct({ applied: Schema.Array(Applied) }) +export const Output = Schema.Struct({ + applied: Schema.Array(Applied), + files: Schema.Array(FileDiff.Info), +}) export type Output = typeof Output.Type +// kilocode_change start - keep durable/SSE tool records bounded without hiding normal-sized diff previews +const compact = (output: Output): Output => { + if (Buffer.byteLength(JSON.stringify(output), "utf-8") <= ToolOutputStore.MAX_BYTES) return output + return { + ...output, + files: output.files.map((file) => ({ + additions: file.additions, + deletions: file.deletions, + ...(file.file === undefined ? {} : { file: file.file }), + ...(file.status === undefined ? {} : { status: file.status }), + })), + } +} +// kilocode_change end + export const toModelOutput = (output: Output) => [ "Applied patch sequentially:", @@ -36,14 +59,20 @@ export const toModelOutput = (output: Output) => ].join("\n") type Prepared = - | (Extract & { readonly target: LocationMutation.Target }) + | (Extract & { + readonly target: LocationMutation.Target + readonly before: string + readonly after: string + }) | (Extract & { readonly target: LocationMutation.Target readonly source: Uint8Array readonly content: string + readonly before: string + readonly after: string }) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service @@ -59,6 +88,8 @@ export const layer = Layer.effectDiscard( "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", input: Input, output: Output, + structured: Output, // kilocode_change + toStructuredOutput: ({ output }) => compact(output), // kilocode_change toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => { const applied: Array = [] @@ -113,29 +144,36 @@ export const layer = Layer.effectDiscard( for (const { hunk, target } of targets) { yield* Effect.gen(function* () { if (hunk.type === "add") { - prepared.push({ ...hunk, target }) + prepared.push({ + ...hunk, + target, + before: "", + after: + hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, + }) return } if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path) + const source = yield* fs.readFile(target.canonical) + const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source) + const before = original.replace(/^\uFEFF/, "") if (hunk.type === "delete") { - prepared.push({ ...hunk, target }) + prepared.push({ ...hunk, target, before, after: "" }) return } - const source = yield* fs.readFile(target.canonical) - const update = Patch.derive( - hunk.path, - hunk.chunks, - new TextDecoder("utf-8", { ignoreBOM: true }).decode(source), - ) + const update = Patch.derive(hunk.path, hunk.chunks, original) prepared.push({ ...hunk, target, source, content: Patch.joinBom(update.content, update.bom), + before, + after: update.content, }) }).pipe(Effect.mapError(() => fail(hunk.path))) } + const patchFiles = prepared.map(patchFile) yield* Effect.forEach( prepared, (change) => @@ -165,7 +203,7 @@ export const layer = Layer.effectDiscard( }).pipe(Effect.mapError(() => fail(change.path))), { discard: true }, ) - return { applied } + return { applied, files: patchFiles } }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) }, }), @@ -175,3 +213,25 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/apply-patch", + layer, + deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], +}) + +function patchFile(change: Prepared): typeof FileDiff.Info.Type { + const counts = diffLines(change.before, change.after).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + return { + file: change.target.resource, + patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after), + status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", + ...counts, + } +} diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index bd6f175adae..228818c2dd2 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -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" @@ -28,21 +30,17 @@ export const Input = Schema.Struct({ .annotate({ description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, }), - description: Schema.String.pipe(Schema.optional).annotate({ - description: "Concise description of the command's purpose", - }), +}) + +const StructuredOutput = Schema.Struct({ + exit: Schema.Number.pipe(Schema.optional), + truncated: Schema.Boolean, + timeout: Schema.Boolean.pipe(Schema.optional), }) const Output = Schema.Struct({ - command: Schema.String, - cwd: Schema.String, - exitCode: Schema.Number.pipe(Schema.optional), - /** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */ + ...StructuredOutput.fields, output: Schema.String, - truncated: Schema.Boolean, - stdoutTruncated: Schema.Boolean.pipe(Schema.optional), - stderrTruncated: Schema.Boolean.pipe(Schema.optional), - timedOut: Schema.Boolean.pipe(Schema.optional), warnings: Schema.Array(Schema.String).pipe(Schema.optional), }) @@ -50,24 +48,12 @@ type Output = typeof Output.Type const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") -const compactOutput = (stdout: string, stderr: string) => { - const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout - return output || "(no output)" -} - -const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => { - if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]" - if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]" - if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]" - return undefined -} - const modelOutput = (output: Output) => { const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" - if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.` - return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.` + if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` + return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` } const isTimeout = (error: AppProcess.AppProcessError) => @@ -104,7 +90,7 @@ const externalCommandDirectories = (command: string, cwd: string) => { return [...directories] } -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service @@ -119,7 +105,16 @@ export const layer = Layer.effectDiscard( description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }], + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ + truncated: output.truncated, + ...(output.exit === undefined ? {} : { exit: output.exit }), + ...(output.timeout === undefined ? {} : { timeout: output.timeout }), + }), + toModelOutput: ({ output }) => [ + { type: "text", text: output.output }, + { type: "text", text: modelOutput(output) }, + ], execute: (input, context) => Effect.gen(function* () { const source = { @@ -166,9 +161,9 @@ export const layer = Layer.effectDiscard( const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const result = yield* appProcess .run(command, { + combineOutput: true, timeout: Duration.millis(timeout), maxOutputBytes: MAX_CAPTURE_BYTES, - maxErrorBytes: MAX_CAPTURE_BYTES, }) .pipe( Effect.catchTag("AppProcessError", (error) => @@ -177,26 +172,22 @@ export const layer = Layer.effectDiscard( ) if (!result) { return { - command: input.command, - cwd: target.canonical, output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, truncated: false, - timedOut: true, + timeout: true, ...(warnings.length ? { warnings } : {}), } } - const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8")) - const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) + const output = result.output?.toString("utf8") || "(no output)" + const notice = result.outputTruncated + ? "[output capture truncated at the in-memory safety limit]" + : undefined return { - command: input.command, - cwd: target.canonical, - exitCode: result.exitCode, - output: notice ? `${compact}\n\n${notice}` : compact, - truncated: result.stdoutTruncated || result.stderrTruncated, + exit: result.exitCode, + output: notice ? `${output}\n\n${notice}` : output, + truncated: result.outputTruncated === true, ...(warnings.length ? { warnings } : {}), - ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), - ...(result.stderrTruncated ? { stderrTruncated: true } : {}), } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }), @@ -204,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], +}) diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 62926202dc1..a558124378d 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -1,5 +1,6 @@ export * as BuiltInTools from "./builtins" +import { makeLocationNode } from "../effect/app-node" import { Layer } from "effect" import { BashTool } from "./bash" import { ApplyPatchTool } from "./apply-patch" @@ -8,7 +9,6 @@ import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { QuestionTool } from "./question" import { ReadTool } from "./read" -import { ReadToolFileSystem } from "./read-filesystem" import { SkillTool } from "./skill" import { TodoWriteTool } from "./todowrite" import { WebFetchTool } from "./webfetch" @@ -28,17 +28,21 @@ import { WriteTool } from "./write" * repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin * transforms separate from this static built-in list. */ -export const locationLayer = Layer.mergeAll( - ApplyPatchTool.layer, - BashTool.layer, - EditTool.layer, - GlobTool.layer, - GrepTool.layer, - QuestionTool.layer, - ReadTool.layer.pipe(Layer.provide(ReadToolFileSystem.layer)), - SkillTool.layer, - TodoWriteTool.layer, - WebFetchTool.layer, - WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)), - WriteTool.layer, -) +export const node = makeLocationNode({ + name: "built-in-tools", + layer: Layer.empty, + deps: [ + ApplyPatchTool.node, + BashTool.node, + EditTool.node, + GlobTool.node, + GrepTool.node, + QuestionTool.node, + ReadTool.node, + SkillTool.node, + TodoWriteTool.node, + WebFetchTool.node, + WebSearchTool.node, + WriteTool.node, + ], +}) diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index fca909d63ce..a92b0836e88 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -9,11 +9,16 @@ export * as EditTool from "./edit" 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 { ToolOutputStore } from "../tool-output-store" // kilocode_change +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -32,14 +37,26 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Struct({ - operation: Schema.Literal("write"), - target: Schema.String, - resource: Schema.String, - existed: Schema.Boolean, + files: Schema.Array(FileDiff.Info), replacements: Schema.Number, }) export type Output = typeof Output.Type +// kilocode_change start - keep durable/SSE tool records bounded without hiding normal-sized diff previews +const compact = (output: Output): Output => { + if (Buffer.byteLength(JSON.stringify(output), "utf-8") <= ToolOutputStore.MAX_BYTES) return output + return { + ...output, + files: output.files.map((file) => ({ + additions: file.additions, + deletions: file.deletions, + ...(file.file === undefined ? {} : { file: file.file }), + ...(file.status === undefined ? {} : { status: file.status }), + })), + } +} +// kilocode_change end + const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n") const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n") const convertToLineEnding = (text: string, ending: "\n" | "\r\n") => @@ -73,7 +90,7 @@ const previewLines = (value: string, prefix: "+" | "-") => { export const toModelOutput = (output: Output, oldString: string, newString: string) => [ - `Edited file successfully: ${output.resource}`, + `Edited file successfully: ${output.files[0]?.file}`, `Replacements: ${output.replacements}`, "```diff", ...previewLines(oldString, "-"), @@ -88,7 +105,7 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service @@ -104,6 +121,8 @@ export const layer = Layer.effectDiscard( "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, + structured: Output, // kilocode_change + toStructuredOutput: ({ output }) => compact(output), // kilocode_change toModelOutput: ({ input, output }) => [ { type: "text", text: toModelOutput(output, input.oldString, input.newString) }, ], @@ -181,6 +200,13 @@ export const layer = Layer.effectDiscard( input.replaceAll === true ? source.text.replaceAll(oldString, newString) : source.text.replace(oldString, newString) + const counts = diffLines(source.text, replaced).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) const next = splitBom(replaced) const result = yield* unableToEdit( files.writeIfUnchanged({ @@ -189,7 +215,17 @@ export const layer = Layer.effectDiscard( content: joinBom(next.text, source.bom || next.bom), }), ) - return { ...result, replacements } satisfies Output + return { + files: [ + { + file: result.resource, + patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced), + status: "modified" as const, + ...counts, + }, + ], + replacements, + } satisfies Output }) }, }), @@ -199,3 +235,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], +}) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index d632f541ff9..8d2d567fa24 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -3,6 +3,7 @@ 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" // kilocode_change start import { FSUtil } from "../fs-util" @@ -13,6 +14,7 @@ import { Reference } from "../reference" // kilocode_change import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -52,7 +54,7 @@ export const toModelOutput = (output: ModelOutput) => { // kilocode_change end /** Glob leaf that defaults its filesystem root to the active Location. */ -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const fs = yield* FSUtil.Service // kilocode_change @@ -124,14 +126,13 @@ export const layer = Layer.effectDiscard( (result) => new Result({ ...result, - items: result.items.map( - (entry) => - new FileSystem.Entry({ - ...entry, - path: RelativePath.make( - path.relative(location.directory, path.resolve(target.path, entry.path)), - ), - }), + items: result.items.map((entry) => + FileSystem.Entry.make({ + ...entry, + path: RelativePath.make( + path.relative(location.directory, path.resolve(target.path, entry.path)), + ), + }), ), }), ), @@ -145,3 +146,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, FSUtil.node, Reference.node], // kilocode_change - search targets and references +}) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 43e913ed1d0..900474b84c5 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -3,6 +3,7 @@ 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" // kilocode_change start @@ -14,6 +15,7 @@ import { Reference } from "../reference" // kilocode_change import { PermissionV2 } from "../permission" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -67,7 +69,7 @@ export const toModelOutput = (output: ModelOutput) => { // kilocode_change end /** Grep leaf that defaults its filesystem root to the active Location. */ -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const fs = yield* FSUtil.Service @@ -150,20 +152,19 @@ export const layer = Layer.effectDiscard( (result) => new Result({ ...result, - items: result.items.map( - (match) => - new FileSystem.Match({ - ...match, - entry: new FileSystem.Entry({ - ...match.entry, - path: RelativePath.make( - path.relative( - location.directory, - path.resolve(cwd, match.entry.path), - ), + items: result.items.map((match) => + FileSystem.Match.make({ + ...match, + entry: FileSystem.Entry.make({ + ...match.entry, + path: RelativePath.make( + path.relative( + location.directory, + path.resolve(cwd, match.entry.path), ), - }), + ), }), + }), ), }), ), @@ -175,3 +176,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, Global.node, Reference.node], // kilocode_change - search targets and references +}) diff --git a/packages/core/src/tool/http-body.ts b/packages/core/src/tool/http-body.ts new file mode 100644 index 00000000000..7cb534a444f --- /dev/null +++ b/packages/core/src/tool/http-body.ts @@ -0,0 +1,30 @@ +import { Effect, Stream } from "effect" +import { HttpClientResponse } from "effect/unstable/http" + +export const collectBoundedResponseBody = ( + response: HttpClientResponse.HttpClientResponse, + maximumBytes: number, + tooLarge: () => Error, +) => + Effect.gen(function* () { + const contentLength = response.headers["content-length"] + const parsedSize = contentLength ? Number.parseInt(contentLength, 10) : undefined + const declaredSize = + parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined + if (declaredSize !== undefined && declaredSize > maximumBytes) return yield* Effect.fail(tooLarge()) + let body = Buffer.allocUnsafe(Math.min(maximumBytes, declaredSize || 64 * 1024)) + let size = 0 + yield* Stream.runForEach(response.stream, (chunk) => { + if (chunk.byteLength === 0) return Effect.void + if (size + chunk.byteLength > maximumBytes) return Effect.fail(tooLarge()) + if (size + chunk.byteLength > body.byteLength) { + const grown = Buffer.allocUnsafe(Math.min(maximumBytes, Math.max(size + chunk.byteLength, body.byteLength * 2))) + body.copy(grown, 0, 0, size) + body = grown + } + body.set(chunk, size) + size += chunk.byteLength + return Effect.void + }) + return body.subarray(0, size) + }) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 6c50a809ecc..e5ae0d7426a 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -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" @@ -42,7 +44,7 @@ export const toModelOutput = ( return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.` } -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const question = yield* QuestionV2.Service @@ -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], +}) diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index 1f2bfa22629..f734bc2a251 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "url" import { Context, Effect, Layer, Option, Schema } from "effect" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" +import { makeLocationNode } from "../effect/app-node" import { AbsolutePath, PositiveInt, RelativePath } from "../schema" export const MAX_READ_LINES = 2_000 @@ -13,23 +14,61 @@ export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024 const MAX_LINE_LENGTH = 2_000 const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` -export class BinaryFileError extends Error { - constructor(readonly resource: string) { - super(`Cannot read binary file: ${resource}`) - this.name = "BinaryFileError" +export class BinaryFileError extends Schema.TaggedErrorClass()("ReadTool.BinaryFileError", { + resource: Schema.String, +}) { + override get message() { + return `Cannot read binary file: ${this.resource}` } } -export class MediaIngestLimitError extends Error { - constructor( - readonly resource: string, - readonly maximumBytes: number, - ) { - super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`) - this.name = "MediaIngestLimitError" +export class MediaIngestLimitError extends Schema.TaggedErrorClass()( + "ReadTool.MediaIngestLimitError", + { + resource: Schema.String, + maximumBytes: Schema.Number, + }, +) { + override get message() { + return `Media exceeds ${this.maximumBytes} byte ingestion limit: ${this.resource}` } } +export class MalformedUtf8Error extends Schema.TaggedErrorClass()("ReadTool.MalformedUtf8Error", { + resource: Schema.String, +}) { + override get message() { + return `File is not valid UTF-8: ${this.resource}` + } +} + +export class OffsetOutOfRangeError extends Schema.TaggedErrorClass()( + "ReadTool.OffsetOutOfRangeError", + { offset: Schema.Number }, +) { + override get message() { + return `Offset ${this.offset} is out of range` + } +} + +export class PathKindError extends Schema.TaggedErrorClass()("ReadTool.PathKindError", { + resource: Schema.String, + expected: Schema.Literals(["a file", "a file or directory"]), +}) { + override get message() { + return `Path is not ${this.expected}: ${this.resource}` + } +} + +export type InspectError = FSUtil.Error | PathKindError +export type ReadError = + | FSUtil.Error + | BinaryFileError + | MediaIngestLimitError + | MalformedUtf8Error + | OffsetOutOfRangeError + | PathKindError + export const PageInput = Schema.Struct({ offset: PositiveInt.pipe(Schema.optional), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), @@ -60,9 +99,13 @@ export interface Target { } export interface Interface { - readonly inspect: (path: AbsolutePath) => Effect.Effect - readonly read: (target: Target, resource: string, page?: PageInput) => Effect.Effect - readonly list: (target: Target, page?: PageInput) => Effect.Effect + readonly inspect: (path: AbsolutePath) => Effect.Effect + readonly read: ( + target: Target, + resource: string, + page?: PageInput, + ) => Effect.Effect + readonly list: (target: Target, page?: PageInput) => Effect.Effect } // kilocode_change end @@ -116,11 +159,21 @@ const binary = (resource: string, bytes: Uint8Array) => { } return nonPrintable / bytes.length > 0.3 } +const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) => + Effect.try({ + try: () => decoder.decode(bytes, { stream: bytes !== undefined }), + catch: (error) => { + if (error instanceof TypeError) return new MalformedUtf8Error({ resource }) + throw error + }, + }) +const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) => + bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes) export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) { - const info = yield* fs.stat(input).pipe(Effect.orDie) + const info = yield* fs.stat(input) const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined - if (!type) return yield* Effect.die(new Error("Path is not a file or directory")) + if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" })) // kilocode_change start - retain the approved identity for descriptor verification const ino = Option.getOrUndefined(info.ino) if (ino === undefined) return yield* Effect.die(new Error("Filesystem identity is unavailable")) @@ -144,31 +197,29 @@ export const read = Effect.fn("ReadTool.read")(function* ( return yield* Effect.scoped( Effect.gen(function* () { // kilocode_change start - open the approved path and verify the descriptor identity - const file = yield* fs.open(target.path, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) + const file = yield* fs.open(target.path, { flag: "r" }) + const info = yield* file.stat yield* Effect.sync(() => verify(target, info)) // kilocode_change end - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" })) const first = Option.getOrElse( - yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie), + yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)), () => new Uint8Array(), ) const mime = imageMime(first) if (mime) { if (info.size > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) const chunks = [first] let total = first.length while (total <= MAX_MEDIA_INGEST_BYTES) { - const chunk = yield* file - .readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) - .pipe(Effect.orDie) + const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) if (Option.isNone(chunk)) break chunks.push(chunk.value) total += chunk.value.length } if (total > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) return { uri: pathToFileURL(target.path).href, name: path.basename(target.path), @@ -180,19 +231,19 @@ export const read = Effect.fn("ReadTool.read")(function* ( mime, } } - if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first)) - return yield* Effect.die(new BinaryFileError(resource)) + if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase())) + return yield* Effect.fail(new BinaryFileError({ resource })) const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined if (!paged) { + if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource })) const decoder = new TextDecoder("utf-8", { fatal: true }) - const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))] + const text = [yield* decodeUtf8(resource, decoder, first)] while (true) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + const chunk = yield* file.readAlloc(64 * 1024) if (Option.isNone(chunk)) break - if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource)) - text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true }))) + text.push(yield* decodeChunk(resource, decoder, chunk.value)) } - text.push(yield* Effect.sync(() => decoder.decode())) + text.push(yield* decodeUtf8(resource, decoder)) return { uri: pathToFileURL(target.path).href, name: path.basename(target.path), @@ -209,34 +260,29 @@ export const read = Effect.fn("ReadTool.read")(function* ( let discard = false let line = 1 let bytes = 0 - let found = false - let truncated = false let next: number | undefined const append = (input: string) => { if (line < offset) { line++ - return + return true } if (lines.length >= limit || bytes >= MAX_READ_BYTES) { - truncated = true - next ??= line++ - return + next = line + return false } - found = true const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) if (bytes + size > MAX_READ_BYTES) { - truncated = true - next ??= line++ - return + next = line + return false } lines.push(text) bytes += size line++ + return true } - const consume = (chunk: Uint8Array) => { - if (chunk.includes(0)) throw new BinaryFileError(resource) - let text = decoder.decode(chunk, { stream: true }) + const consume = (input: string) => { + let text = input while (true) { const index = text.indexOf("\n") if (index === -1) { @@ -253,31 +299,44 @@ export const read = Effect.fn("ReadTool.read")(function* ( pending = "" discard = false text = text.slice(index + 1) - append(current.endsWith("\r") ? current.slice(0, -1) : current) - if (truncated) break // kilocode_change - stop after the first extra line proves another page exists + if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false } + return true } - yield* Effect.sync(() => consume(first)) - while (true) { - if (truncated) break // kilocode_change - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { + let start = 0 + while (start < chunk.length) { + if (lines.length >= limit || bytes >= MAX_READ_BYTES) { + next = line + return false + } + const newline = chunk.indexOf(10, start) + const end = newline === -1 ? chunk.length : newline + 1 + const segment = chunk.subarray(start, end) + if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource })) + if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false + start = end + } + return true + }) + let done = !(yield* consumeChunk(first)) + while (!done) { + const chunk = yield* file.readAlloc(64 * 1024) if (Option.isNone(chunk)) break - yield* Effect.sync(() => consume(chunk.value)) + done = !(yield* consumeChunk(chunk.value)) } - // kilocode_change start - a completed page must not decode the rest of the file - if (!truncated) { - const tail = yield* Effect.sync(() => decoder.decode()) + if (!done) { + const tail = yield* decodeUtf8(resource, decoder) if (!discard) pending += tail if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) } - // kilocode_change end - if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`)) + if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset })) return new TextPage({ type: "text-page", content: lines.join("\n"), mime: FSUtil.mimeType(target.path), offset, - truncated, + truncated: next !== undefined, ...(next === undefined ? {} : { next }), }) }), @@ -286,10 +345,10 @@ export const read = Effect.fn("ReadTool.read")(function* ( // kilocode_change - verify approved directory identity before listing export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, target: Target, page: PageInput = {}) { - const info = yield* fs.stat(target.path).pipe(Effect.orDie) + const info = yield* fs.stat(target.path) yield* Effect.sync(() => verify(target, info)) const root = target.path - const items = yield* fs.readDirectoryEntries(root).pipe(Effect.orDie) + const items = yield* fs.readDirectoryEntries(root) const offset = page.offset ?? 1 const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) const entries = yield* Effect.forEach( @@ -302,10 +361,9 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void)) const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined if (!type) return - return new FileSystem.Entry({ + return FileSystem.Entry.make({ path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")), type, - mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target), }) }), { concurrency: 16 }, @@ -318,7 +376,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, return new ListPage({ entries: selected, truncated, ...(truncated ? { next: offset + selected.length } : {}) }) }) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -329,3 +387,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index b6f4ada42a9..f43fd1bea07 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -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" // kilocode_change 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" @@ -25,7 +27,7 @@ const LocationInput = Schema.Struct({ const Input = LocationInput const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage]) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const reader = yield* ReadToolFileSystem.Service @@ -37,7 +39,7 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description: - "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.", + "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", input: Input, output: Output, toModelOutput: ({ input, output }) => { @@ -50,30 +52,35 @@ export const layer = Layer.effectDiscard( }, execute: (input, context) => { return Effect.gen(function* () { - // kilocode_change start - retain external-directory authorization and canonical resources - const resolved = yield* mutation.resolve({ path: input.path }) - const resource = resolved.resource - const target = yield* reader.inspect(AbsolutePath.make(resolved.canonical)) - if (resolved.externalDirectory) { + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + const target = yield* mutation.resolve({ path: input.path, kind: "directory" }) + const external = target.externalDirectory + if (external) yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(resolved.externalDirectory), + ...LocationMutation.externalDirectoryPermission(external), sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source, }) - } - // kilocode_change end + const resource = target.resource + const absolute = AbsolutePath.make(target.canonical) + // kilocode_change - retain the approved filesystem identity through the read + const approved = yield* reader.inspect(absolute) yield* permission.assert({ action: name, resources: [resource], save: ["*"], sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source, }) - if (target.type === "directory") - return yield* reader.list(target, { offset: input.offset, limit: input.limit }) // kilocode_change - approved identity - const content = yield* reader.read(target, resource, { + if (approved.type === "directory") + return yield* reader.list(approved, { offset: input.offset, limit: input.limit }) + const content = yield* reader.read(approved, resource, { offset: input.offset, limit: input.limit, }) // kilocode_change - approved identity @@ -83,7 +90,7 @@ export const layer = Layer.effectDiscard( .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) } if ("encoding" in content && content.encoding === "base64") - return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource)) + return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return content }).pipe( Effect.mapError((error) => { @@ -103,3 +110,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], +}) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index d99cc9014c4..1c2dfe7ab45 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -11,6 +11,7 @@ import { Wildcard } from "../util/wildcard" import { ApplicationTools } from "./application-tools" import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" +import { makeLocationNode } from "../effect/app-node" export type ExecuteInput = { readonly sessionID: SessionSchema.ID @@ -123,7 +124,7 @@ const registryLayer = Layer.effect( }), ) -export const layer = Layer.effect( +const layer = Layer.effect( Tools.Service, Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), ).pipe(Layer.provideMerge(registryLayer)) @@ -133,7 +134,14 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { return rule?.resource === "*" && rule.effect === "deny" } -export const defaultLayer = layer.pipe( - Layer.provide(ApplicationTools.layer), - Layer.provide(ToolOutputStore.defaultLayer), -) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ApplicationTools.node, ToolOutputStore.node], +}) + +export const toolsNode = makeLocationNode({ + service: Tools.Service, + layer, + deps: [ApplicationTools.node, ToolOutputStore.node], +}) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 589a99d4622..1f8b1229035 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -1,13 +1,13 @@ export * as SkillTool from "./skill" import path from "path" -import { pathToFileURL } from "url" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FSUtil } from "../fs-util" -import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -40,7 +40,7 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) "", skill.content.trim(), "", - `Base directory for this skill: ${pathToFileURL(directory).href}`, + `Base directory for this skill: ${directory}`, "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", "Note: file list is sampled.", "", @@ -54,14 +54,12 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) const unableToLoad = (name: string, error?: unknown) => new ToolFailure({ message: `Unable to load skill ${name}`, error }) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const fs = yield* FSUtil.Service - const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service const permission = yield* PermissionV2.Service - yield* boot.wait() yield* tools .register({ [name]: Tool.make({ @@ -103,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], +}) diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index a746d524f0e..bc1ba1fbb3f 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -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" @@ -20,7 +22,7 @@ export type Output = typeof Output.Type export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const todos = yield* SessionTodo.Service @@ -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], +}) diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index eb70f2cb474..1d9a82e9522 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -37,10 +37,19 @@ export type Content = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } -type Config, Output extends SchemaType> = { +type Config< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { readonly description: string readonly input: Input readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => Schema.Schema.Type readonly execute: ( input: Schema.Schema.Type, context: Context, @@ -59,10 +68,12 @@ type Runtime = { const runtimes = new WeakMap() -export function make, Output extends SchemaType>( - config: Config, -): Definition { - const tool = Object.freeze({}) as Definition +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition { + const tool = Object.freeze({}) as Definition const definitions = new Map() runtimes.set(tool, { definition: (name) => { @@ -72,7 +83,7 @@ export function make, Output extends SchemaType, Output extends SchemaType Schema.encodeEffect(config.output)(output).pipe( + Effect.flatMap((output) => { + if (!config.structured || !config.toStructuredOutput) + return Effect.succeed({ output, structured: output }) + return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe( + Effect.map((structured) => ({ output, structured })), + ) + }), Effect.mapError( (error) => new ToolFailure({ @@ -92,8 +110,8 @@ export function make, Output extends SchemaType ({ - structured: output, + Effect.map(({ output, structured }) => ({ + structured, content: config.toModelOutput?.({ input, output }).map((part) => part.type === "text" diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 1e209e50086..d3889d6a7ab 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,11 +1,15 @@ export * as WebFetchTool from "./webfetch" import { ToolFailure } from "@opencode-ai/llm" -import { Duration, Effect, Layer, Schema, Stream } from "effect" +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" @@ -86,24 +90,11 @@ const execute = (http: HttpClient.HttpClient, url: string, format: Format, userA http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)) const collectBody = (response: HttpClientResponse.HttpClientResponse) => - Effect.gen(function* () { - const contentLength = response.headers["content-length"] - if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) { - return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) - } - const chunks: Uint8Array[] = [] - let size = 0 - yield* Stream.runForEach(response.stream, (chunk) => - Effect.gen(function* () { - size += chunk.byteLength - if (size > MAX_RESPONSE_BYTES) - return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) - chunks.push(chunk) - return undefined - }), - ) - return Buffer.concat(chunks, size) - }) + collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`), + ) const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "" const isImageAttachment = (mime: string) => @@ -124,7 +115,7 @@ const convert = (content: string, contentType: string, format: Format) => { return content } -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const http = yield* HttpClient.HttpClient @@ -171,12 +162,16 @@ export const layer = Layer.effectDiscard( orElse: () => Effect.fail(new Error("Request timed out")), }), ) - const content = convert(new TextDecoder().decode(body), contentType, input.format) + const content = new TextDecoder().decode(body) + const output = yield* Effect.try({ + try: () => convert(content, contentType, input.format), + catch: (error) => error, + }) return { url: input.url, contentType, format: input.format, - output: content, + output, } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }), @@ -185,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 diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 6513eefbdae..f3de0c1a483 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -3,13 +3,17 @@ 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" import { PermissionV2 } from "../permission" import { Tool } from "./tool" import { Tools } from "./tools" +import { collectBoundedResponseBody } from "./http-body" import { checksum } from "../util/encode" +import { ToolRegistry } from "./registry" export const name = "websearch" export const NO_RESULTS = "No search results found. Please try a different query." @@ -79,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 = { enableExa: false, enableParallel: false }, @@ -164,10 +170,12 @@ const callMcp = ( ) return yield* Effect.gen(function* () { const response = yield* HttpClient.filterStatusOk(http).execute(request) - const body = yield* response.text - if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) - return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`)) - return yield* parseResponse(body) + const body = yield* collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), + ) + return yield* parseResponse(body.toString("utf8")) }).pipe( Effect.timeoutOrElse({ duration: Duration.seconds(25), @@ -181,7 +189,7 @@ const Output = Schema.Struct({ text: Schema.String, }) -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const http = yield* HttpClient.HttpClient @@ -244,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], +}) diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 8653ee682f5..7ed171bc4b9 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -10,9 +10,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" @@ -44,7 +46,7 @@ export const toModelOutput = (output: Output) => // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. -export const layer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service @@ -93,3 +95,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], +}) diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index 2ba5ef0d759..b85900118a3 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -6,7 +6,7 @@ import type { FileSystem, Scope } from "effect" import type { PlatformError } from "effect/PlatformError" import { FSUtil } from "../fs-util" import { Global } from "../global" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/app-node" import { Hash } from "./hash" export namespace EffectFlock { @@ -24,7 +24,7 @@ export namespace EffectFlock { class ReleaseError extends Schema.TaggedErrorClass()("ReleaseError", { detail: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { override get message() { return this.detail @@ -94,7 +94,7 @@ export namespace EffectFlock { const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown" - export const layer: Layer.Layer = Layer.effect( + const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const global = yield* Global.Service @@ -280,6 +280,5 @@ export namespace EffectFlock { }), ) - export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer)) - export const node = LayerNode.make(layer, [Global.node, FSUtil.node]) + export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] }) } diff --git a/packages/core/src/util/identifier.ts b/packages/core/src/util/identifier.ts index ba28a351ba1..442fbb39833 100644 --- a/packages/core/src/util/identifier.ts +++ b/packages/core/src/util/identifier.ts @@ -1,48 +1 @@ -import { randomBytes } from "crypto" - -export namespace Identifier { - const LENGTH = 26 - - // State for monotonic ID generation - let lastTimestamp = 0 - let counter = 0 - - export function ascending() { - return create(false) - } - - export function descending() { - return create(true) - } - - function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - let result = "" - const bytes = randomBytes(length) - for (let i = 0; i < length; i++) { - result += chars[bytes[i] % 62] - } - return result - } - - export function create(descending: boolean, timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - counter++ - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - now = descending ? ~now : now - - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) - } - - return timeBytes.toString("hex") + randomBase62(LENGTH - 12) - } -} +export * as Identifier from "@opencode-ai/schema/identifier" diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 77f5d55160d..8b58a7f3da8 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -129,6 +129,10 @@ export const Info = Schema.Struct({ hide_prompt_training_models: Schema.optional(Schema.Boolean).annotate({ description: "Hide Kilo Gateway models that may train on your prompts from model listings", }), + privacy_mode: Schema.optional(Schema.Boolean).annotate({ + description: + "Blur personally identifiable information (account email, balance, team name, etc.) in the TUI and require confirmation before showing profile details", + }), sandbox: Schema.optional( Schema.Struct({ enabled: Schema.optional( diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index d56074b940c..959872dcb4f 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -6,7 +6,6 @@ import { ConfigMCPV1 } from "./mcp" import { ConfigPermissionV1 } from "./permission" import { ConfigProviderV1 } from "./provider" import { ConfigProviderOptionsV1 } from "./provider-options" -import { ModelRequest } from "../../model-request" const keys = new Set([ "logLevel", @@ -139,7 +138,7 @@ function mcp(info: typeof ConfigV1.Info.Type) { ) const timeout = info.experimental?.mcp_timeout if (!timeout && !Object.keys(servers).length) return undefined - return { timeout, servers } + return { timeout: timeout === undefined ? undefined : { request: timeout }, servers } } function migrateMcp(info: ConfigMCPV1.Info) { @@ -151,7 +150,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { cwd: info.cwd, environment: info.environment, disabled, - timeout: info.timeout, + timeout: info.timeout === undefined ? undefined : { request: info.timeout }, } return { type: info.type, @@ -165,7 +164,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { redirect_uri: info.oauth.redirectUri, }, disabled, - timeout: info.timeout, + timeout: info.timeout === undefined ? undefined : { request: info.timeout }, } } @@ -182,6 +181,7 @@ function providers(info?: Readonly> function migrateProvider(info: ConfigProviderV1.Info) { const lowerer = ConfigProviderOptionsV1.get(info.npm) const options = lowerer.provider(info.options ?? {}) + const url = info.api ?? options.url return { name: info.name, env: info.env, @@ -189,7 +189,7 @@ function migrateProvider(info: ConfigProviderV1.Info) { ? { type: "aisdk" as const, package: info.npm, - url: info.api ?? options.url, + ...(url === undefined ? {} : { url }), settings: options.settings ?? {}, } : undefined, @@ -208,11 +208,7 @@ function migrateProvider(info: ConfigProviderV1.Info) { function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: string) { const packageID = info.provider?.npm ?? packageName const lowerer = ConfigProviderOptionsV1.get(packageID) - const ingest = (options: Readonly>) => { - const request = ModelRequest.normalizeAiSdkOptions(packageID, options) - return { ...lowerer.request(request.body), ...request.generation, ...request.options } - } - const request = info.options && ingest(info.options) + const request = info.options && lowerer.request(info.options) const costs = info.cost && [ { input: info.cost.input, @@ -242,7 +238,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st ...(info.id === undefined ? {} : { id: info.id }), type: "aisdk" as const, package: info.provider.npm, - url: info.provider.api, + ...(info.provider.api === undefined ? {} : { url: info.provider.api }), settings: {}, } : info.id === undefined @@ -260,7 +256,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st .filter((entry): entry is [string, NonNullable<(typeof info.variants)[string]>] => entry[1] !== null) .map(([id, options]) => ({ id, - body: ingest(options), + body: lowerer.request(options), })), cost: costs, disabled: info.status === "deprecated" ? true : undefined, diff --git a/packages/core/src/v1/config/provider-options.ts b/packages/core/src/v1/config/provider-options.ts index a441a1a211d..6bf0bd9e1aa 100644 --- a/packages/core/src/v1/config/provider-options.ts +++ b/packages/core/src/v1/config/provider-options.ts @@ -40,7 +40,23 @@ const openai: Lowerer = { settings: omit(options, ["apiKey", "baseURL", "organization", "project", "headers", "body"]), } }, - request: snake, + request(options) { + const result = snake(options) + if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) { + result.reasoning = { + ...(isRecord(result.reasoning) ? result.reasoning : {}), + ...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}), + ...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}), + } + delete result.reasoning_effort + delete result.reasoning_summary + } + if (options.textVerbosity !== undefined) { + result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity } + delete result.text_verbosity + } + return result + }, } const anthropic: Lowerer = { diff --git a/packages/core/src/v1/permission.ts b/packages/core/src/v1/permission.ts index 8c410562e61..c289196c182 100644 --- a/packages/core/src/v1/permission.ts +++ b/packages/core/src/v1/permission.ts @@ -1,73 +1,8 @@ export * as PermissionV1 from "./permission" import { Schema } from "effect" -import { ProjectV2 } from "../project" -import { withStatics } from "../schema" -import { SessionSchema } from "../session/schema" -import { Identifier } from "../util/identifier" - -export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( - Schema.brand("PermissionID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })), -) -export type ID = typeof ID.Type - -export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) -export type Action = typeof Action.Type - -export const Rule = Schema.Struct({ - permission: Schema.String, - pattern: Schema.String, - action: Action, -}).annotate({ identifier: "PermissionRule" }) -export type Rule = typeof Rule.Type - -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) -export type Ruleset = typeof Ruleset.Type - -export const Request = Schema.Struct({ - id: ID, - sessionID: SessionSchema.ID, - permission: Schema.String, - patterns: Schema.Array(Schema.String), - metadata: Schema.Record(Schema.String, Schema.Unknown), - always: Schema.Array(Schema.String), - tool: Schema.Struct({ - messageID: Schema.String, - callID: Schema.String, - }).pipe(Schema.optional), -}).annotate({ identifier: "PermissionRequest" }) -export type Request = typeof Request.Type - -export const Reply = Schema.Literals(["once", "always", "reject"]) -export type Reply = typeof Reply.Type - -export const ReplyBody = Schema.Struct({ - reply: Reply, - message: Schema.String.pipe(Schema.optional), - // kilocode_change - set by clients when a human answered the prompt; the server refuses machine approvals of skill-shell batches - interactive: Schema.Boolean.pipe(Schema.optional), -}).annotate({ identifier: "PermissionReplyBody" }) -export type ReplyBody = typeof ReplyBody.Type - -export const Approval = Schema.Struct({ - projectID: ProjectV2.ID, - patterns: Schema.Array(Schema.String), -}).annotate({ identifier: "PermissionApproval" }) -export type Approval = typeof Approval.Type - -export const AskInput = Schema.Struct({ - ...Request.fields, - id: ID.pipe(Schema.optional), - ruleset: Ruleset, -}).annotate({ identifier: "PermissionAskInput" }) -export type AskInput = typeof AskInput.Type - -export const ReplyInput = Schema.Struct({ - requestID: ID, - ...ReplyBody.fields, -}).annotate({ identifier: "PermissionReplyInput" }) -export type ReplyInput = typeof ReplyInput.Type +export * from "@opencode-ai/schema/permission-v1" +import { ID } from "@opencode-ai/schema/permission-v1" export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { override get message() { diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index cdc9d5a5890..b4ce31e5f09 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -1,39 +1,52 @@ export * as SessionV1 from "./session" -import { Effect, Schema, Types } from "effect" -import { EventV2 } from "../event" -import { PermissionV1 } from "./permission" -import { ProjectV2 } from "../project" -import { ProviderV2 } from "../provider" -import { ModelV2 } from "../model" -import { optionalOmitUndefined, withStatics } from "../schema" -import { Identifier } from "../util/identifier" +import { Schema } from "effect" import { NonNegativeInt } from "../schema" import { NamedError } from "../util/error" -import { SessionSchema } from "../session/schema" -import { WorkspaceV2 } from "../workspace" -const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) - -export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( - Schema.brand("MessageID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })), -) -export type MessageID = typeof MessageID.Type - -export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( - Schema.brand("PartID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + Identifier.ascending()) })), -) -export type PartID = typeof PartID.Type +export { + AgentPart, + AgentPartInput, + Assistant, + CompactionPart, + Event, + FilePart, + FilePartInput, + FilePartSource, + FileSource, + Format, + Info, + MessageID, + OutputFormatJsonSchema, + OutputFormatText, + Part, + PartID, + PatchPart, + Range, + ReasoningPart, + ResourceSource, + RetryPart, + SessionInfo, + SnapshotPart, + StepFinishPart, + StepStartPart, + SubtaskPart, + SubtaskPartInput, + SymbolSource, + TextPart, + TextPartInput, + ToolPart, + ToolState, + ToolStateCompleted, + ToolStateError, + ToolStatePending, + ToolStateRunning, + User, + WithParts, +} from "@opencode-ai/schema/session-v1" export const OutputLengthError = NamedError.create("MessageOutputLengthError", {}) - -export const AuthError = NamedError.create("ProviderAuthError", { - providerID: Schema.String, - message: Schema.String, -}) - +export const AuthError = NamedError.create("ProviderAuthError", { providerID: Schema.String, message: Schema.String }) export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String }) export const StructuredOutputError = NamedError.create("StructuredOutputError", { message: Schema.String, @@ -52,636 +65,4 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), }) -export const ContentFilterError = NamedError.create("ContentFilterError", { - message: Schema.String, -}) - -export class OutputFormatText extends Schema.Class("OutputFormatText")({ - type: Schema.Literal("text"), -}) {} - -export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ - type: Schema.Literal("json_schema"), - schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), - retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), -}) {} - -export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ - discriminator: "type", - identifier: "OutputFormat", -}) -export type OutputFormat = Schema.Schema.Type - -const partBase = { - id: PartID, - sessionID: SessionSchema.ID, - messageID: MessageID, -} - -export const SnapshotPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("snapshot"), - snapshot: Schema.String, -}).annotate({ identifier: "SnapshotPart" }) -export type SnapshotPart = Types.DeepMutable> - -export const PatchPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("patch"), - hash: Schema.String, - files: Schema.Array(Schema.String), -}).annotate({ identifier: "PatchPart" }) -export type PatchPart = Types.DeepMutable> - -export const TextPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPart" }) -export type TextPart = Types.DeepMutable> - -export const ReasoningPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("reasoning"), - text: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), -}).annotate({ identifier: "ReasoningPart" }) -export type ReasoningPart = Types.DeepMutable> - -const filePartSourceBase = { - text: Schema.Struct({ - value: Schema.String, - start: Schema.Finite, - end: Schema.Finite, - }).annotate({ identifier: "FilePartSourceText" }), -} - -export const Range = Schema.Struct({ - start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), - end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), -}).annotate({ identifier: "Range" }) -export type Range = typeof Range.Type - -export const FileSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("file"), - path: Schema.String, -}).annotate({ identifier: "FileSource" }) - -export const SymbolSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("symbol"), - path: Schema.String, - range: Range, - name: Schema.String, - kind: NonNegativeInt, -}).annotate({ identifier: "SymbolSource" }) - -export const ResourceSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("resource"), - clientName: Schema.String, - uri: Schema.String, -}).annotate({ identifier: "ResourceSource" }) - -export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ - discriminator: "type", - identifier: "FilePartSource", -}) - -export const FilePart = Schema.Struct({ - ...partBase, - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePart" }) -export type FilePart = Types.DeepMutable> - -export const AgentPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPart" }) -export type AgentPart = Types.DeepMutable> - -export const CompactionPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("compaction"), - auto: Schema.Boolean, - overflow: Schema.optional(Schema.Boolean), - tail_start_id: Schema.optional(MessageID), -}).annotate({ identifier: "CompactionPart" }) -export type CompactionPart = Types.DeepMutable> - -export const SubtaskPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - }), - ), - variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPart" }) -export type SubtaskPart = Types.DeepMutable> - -export const RetryPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("retry"), - attempt: NonNegativeInt, - error: APIError.EffectSchema, - time: Schema.Struct({ - created: NonNegativeInt, - }), -}).annotate({ identifier: "RetryPart" }) -export type RetryPart = Omit>, "error"> & { - error: APIError -} - -export const StepStartPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-start"), - snapshot: Schema.optional(Schema.String), - // kilocode_change start - wall-clock timestamps captured at the processor - // and consumed by the webview's weighted throughput aggregator. Marked - // optional so older persisted sessions (and synthetic messages) without - // timing still decode cleanly. - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - }), - ), - // kilocode_change end -}).annotate({ identifier: "StepStartPart" }) -export type StepStartPart = Types.DeepMutable> - -export const StepFinishPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-finish"), - reason: Schema.String, - snapshot: Schema.optional(Schema.String), - // kilocode_change start - model: Schema.optional( - Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - }), - ), - generationID: Schema.optional(Schema.String), // kilocode_change - vercelID: Schema.optional(Schema.String), // kilocode_change - metrics: Schema.optional( - Schema.Struct({ - prompt: Schema.optional(Schema.Finite), - generation: Schema.optional(Schema.Finite), - source: Schema.Literals(["provider", "computed"]), - }), - ), - // Wall-clock timestamps + active generation duration captured at the - // session processor. The webview's weighted throughput aggregator uses - // `time.elapsed` (active model-generation duration in milliseconds, - // excluding tool execution and idle waiting) to weight the per-turn - // rate. Optional so legacy persisted sessions keep decoding. - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - elapsed: Schema.Finite, - }), - ), - // kilocode_change end - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), -}).annotate({ identifier: "StepFinishPart" }) -export type StepFinishPart = Types.DeepMutable> - -export const ToolStatePending = Schema.Struct({ - status: Schema.Literal("pending"), - input: Schema.Record(Schema.String, Schema.Any), - raw: Schema.String, -}).annotate({ identifier: "ToolStatePending" }) -export type ToolStatePending = Types.DeepMutable> - -export const ToolStateRunning = Schema.Struct({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Any), - title: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateRunning" }) -export type ToolStateRunning = Types.DeepMutable> - -export const ToolStateCompleted = Schema.Struct({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Any), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Any), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - compacted: Schema.optional(NonNegativeInt), - }), - attachments: Schema.optional(Schema.Array(FilePart)), -}).annotate({ identifier: "ToolStateCompleted" }) -export type ToolStateCompleted = Types.DeepMutable> - -export const ToolStateError = Schema.Struct({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Any), - error: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateError" }) -export type ToolStateError = Types.DeepMutable> - -export const ToolState = Schema.Union([ - ToolStatePending, - ToolStateRunning, - ToolStateCompleted, - ToolStateError, -]).annotate({ - discriminator: "status", - identifier: "ToolState", -}) -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError - -export const ToolPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("tool"), - callID: Schema.String, - tool: Schema.String, - state: ToolState, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "ToolPart" }) -export type ToolPart = Omit>, "state"> & { - state: ToolState -} - -const messageBase = { - id: MessageID, - sessionID: partBase.sessionID, -} - -const FileDiff = Schema.Struct({ - file: Schema.optional(Schema.String), - patch: Schema.optional(Schema.String), - additions: Schema.Finite, - deletions: Schema.Finite, - status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), -}).annotate({ identifier: "SnapshotFileDiff" }) - -// kilocode_change start -export const EditorContext = Schema.Struct({ - visibleFiles: Schema.optional(Schema.Array(Schema.String)), - openTabs: Schema.optional(Schema.Array(Schema.String)), - activeFile: Schema.optional(Schema.String), - shell: Schema.optional(Schema.String), -}) -export type EditorContext = Types.DeepMutable> -// kilocode_change end - -export const User = Schema.Struct({ - ...messageBase, - role: Schema.Literal("user"), - time: Schema.Struct({ - created: Timestamp, - }), - format: Schema.optional(Format), - summary: Schema.optional( - Schema.Struct({ - title: Schema.optional(Schema.String), - body: Schema.optional(Schema.String), - diffs: Schema.Array(FileDiff), - }), - ), - agent: Schema.String, - model: Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - variant: Schema.optional(Schema.String), - }), - system: Schema.optional(Schema.String), - tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), - // kilocode_change start - editorContext: Schema.optional(EditorContext), - // kilocode_change end -}).annotate({ identifier: "UserMessage" }) -export type User = Types.DeepMutable> - -export const Part = Schema.Union([ - TextPart, - SubtaskPart, - ReasoningPart, - FilePart, - ToolPart, - StepStartPart, - StepFinishPart, - SnapshotPart, - PatchPart, - AgentPart, - RetryPart, - CompactionPart, -]).annotate({ discriminator: "type", identifier: "Part" }) -export type Part = - | TextPart - | SubtaskPart - | ReasoningPart - | FilePart - | ToolPart - | StepStartPart - | StepFinishPart - | SnapshotPart - | PatchPart - | AgentPart - | RetryPart - | CompactionPart - -const AssistantErrorSchema = Schema.Union([ - AuthError.EffectSchema, - NamedError.Unknown.EffectSchema, - OutputLengthError.EffectSchema, - AbortedError.EffectSchema, - StructuredOutputError.EffectSchema, - ContextOverflowError.EffectSchema, - ContentFilterError.EffectSchema, - APIError.EffectSchema, -]).annotate({ discriminator: "name" }) -type AssistantError = Schema.Schema.Type - -export const TextPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPartInput" }) -export type TextPartInput = Types.DeepMutable> - -export const FilePartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePartInput" }) -export type FilePartInput = Types.DeepMutable> - -export const AgentPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPartInput" }) -export type AgentPartInput = Types.DeepMutable> - -export const SubtaskPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - }), - ), - variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPartInput" }) -export type SubtaskPartInput = Types.DeepMutable> - -export const Assistant = Schema.Struct({ - ...messageBase, - role: Schema.Literal("assistant"), - time: Schema.Struct({ - created: NonNegativeInt, - completed: Schema.optional(NonNegativeInt), - }), - error: Schema.optional(AssistantErrorSchema), - parentID: MessageID, - modelID: ModelV2.ID, - providerID: ProviderV2.ID, - mode: Schema.String, - agent: Schema.String, - path: Schema.Struct({ - cwd: Schema.String, - root: Schema.String, - }), - summary: Schema.optional(Schema.Boolean), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - structured: Schema.optional(Schema.Any), - variant: Schema.optional(Schema.String), - finish: Schema.optional(Schema.String), -}).annotate({ identifier: "AssistantMessage" }) -export type Assistant = Omit>, "error"> & { - error?: AssistantError -} - -export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) -export type Info = User | Assistant - -export const WithParts = Schema.Struct({ - info: Info, - parts: Schema.Array(Part), -}) -export type WithParts = { - info: Info - parts: Part[] -} - -const options = { - sync: { - aggregate: "sessionID", - version: 1, - }, -} as const - -const SessionSummary = Schema.Struct({ - additions: Schema.Finite, - deletions: Schema.Finite, - files: Schema.Finite, - diffs: optionalOmitUndefined(Schema.Array(FileDiff)), -}) - -const SessionTokens = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), -}) - -const SessionShare = Schema.Struct({ - url: Schema.String, -}) - -const SessionRevert = Schema.Struct({ - messageID: MessageID, - partID: optionalOmitUndefined(PartID), - snapshot: optionalOmitUndefined(Schema.String), - diff: optionalOmitUndefined(Schema.String), - workspace: optionalOmitUndefined(Schema.Literals(["restored", "snapshots-disabled", "unavailable"])), // kilocode_change -}) - -const SessionModel = Schema.Struct({ - id: ModelV2.ID, - providerID: ProviderV2.ID, - variant: optionalOmitUndefined(Schema.String), -}) - -export const SessionInfo = Schema.Struct({ - id: SessionSchema.ID, - slug: Schema.String, - projectID: ProjectV2.ID, - workspaceID: optionalOmitUndefined(WorkspaceV2.ID), - directory: Schema.String, - path: optionalOmitUndefined(Schema.String), - parentID: optionalOmitUndefined(SessionSchema.ID), - summary: optionalOmitUndefined(SessionSummary), - cost: optionalOmitUndefined(Schema.Finite), - tokens: optionalOmitUndefined(SessionTokens), - share: optionalOmitUndefined(SessionShare), - title: Schema.String, - agent: optionalOmitUndefined(Schema.String), - model: optionalOmitUndefined(SessionModel), - version: Schema.String, - metadata: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - created: NonNegativeInt, - updated: NonNegativeInt, - compacting: optionalOmitUndefined(NonNegativeInt), - archived: optionalOmitUndefined(Schema.Finite), - }), - permission: optionalOmitUndefined(PermissionV1.Ruleset), - revert: optionalOmitUndefined(SessionRevert), -}).annotate({ identifier: "Session" }) -export type SessionInfo = typeof SessionInfo.Type - -export const Event = { - Created: EventV2.define({ - type: "session.created", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: SessionInfo, - }, - }), - Updated: EventV2.define({ - type: "session.updated", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: SessionInfo, - }, - }), - Deleted: EventV2.define({ - type: "session.deleted", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: SessionInfo, - }, - }), - MessageUpdated: EventV2.define({ - type: "message.updated", - ...options, - schema: { - sessionID: SessionSchema.ID, - info: Info, - }, - }), - MessageRemoved: EventV2.define({ - type: "message.removed", - ...options, - schema: { - sessionID: SessionSchema.ID, - messageID: MessageID, - }, - }), - PartUpdated: EventV2.define({ - type: "message.part.updated", - ...options, - schema: { - sessionID: SessionSchema.ID, - part: Part, - time: Schema.Finite, - }, - }), - PartRemoved: EventV2.define({ - type: "message.part.removed", - ...options, - schema: { - sessionID: SessionSchema.ID, - messageID: MessageID, - partID: PartID, - }, - }), -} +export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String }) diff --git a/packages/core/src/v2-schema.ts b/packages/core/src/v2-schema.ts index a34b0b15169..4dfd51d92bd 100644 --- a/packages/core/src/v2-schema.ts +++ b/packages/core/src/v2-schema.ts @@ -1,10 +1,3 @@ -import { DateTime, Schema, SchemaGetter } from "effect" - -export const DateTimeUtcFromMillis = Schema.Finite.pipe( - Schema.decodeTo(Schema.DateTimeUtc, { - decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), - encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), - }), -) - export * as V2Schema from "./v2-schema" + +export { DateTimeUtcFromMillis } from "@opencode-ai/schema/schema" diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index 30d33abbee6..d85bbe4ba47 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -1,18 +1,6 @@ export * as WorkspaceV2 from "./workspace" -import { Schema } from "effect" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" +import { Workspace } from "@opencode-ai/schema/workspace" -export const ID = Schema.String.check(Schema.isStartsWith("wrk")).pipe( - Schema.brand("WorkspaceV2.ID"), - withStatics((schema) => ({ - ascending: (id?: string) => { - if (!id) return schema.make("wrk_" + Identifier.ascending()) - if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`) - return schema.make(id) - }, - create: () => schema.make("wrk_" + Identifier.ascending()), - })), -) +export const ID = Workspace.ID export type ID = typeof ID.Type diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 9f46eca4e96..f4c023e5d5a 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -1,13 +1,15 @@ 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" 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", () => @@ -23,9 +25,7 @@ describe("AgentV2", () => { Effect.gen(function* () { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("reviewer") - const transform = yield* agent.transform() - - yield* transform((editor) => + yield* agent.transform((editor) => editor.update(id, (info) => { info.description = "Reviews code" info.mode = "subagent" @@ -41,19 +41,17 @@ describe("AgentV2", () => { Effect.gen(function* () { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("reviewer") - const transform = yield* agent.transform() - - yield* transform((editor) => + let description = "Old description" + let hidden = true + yield* agent.transform((editor) => editor.update(id, (info) => { - info.description = "Old description" - info.hidden = true - }), - ) - yield* transform((editor) => - editor.update(id, (info) => { - info.description = "New description" + info.description = description + info.hidden = hidden }), ) + description = "New description" + hidden = false + yield* agent.reload() expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) }), @@ -64,9 +62,7 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("scoped") const scope = yield* Scope.make() - const transform = yield* agent.transform().pipe(Scope.provide(scope)) - - yield* transform((editor) => editor.update(id, () => {})) + yield* agent.transform((editor) => editor.update(id, () => {})).pipe(Scope.provide(scope)) expect(yield* agent.get(id)).toBeDefined() yield* Scope.close(scope, Exit.void) @@ -79,7 +75,7 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("build") - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(id, (info) => { info.mode = "primary" info.hidden = true @@ -95,10 +91,10 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("custom") - yield* agent.update((editor) => editor.update(id, () => {})) + yield* agent.transform((editor) => editor.update(id, () => {})) expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id)) - yield* agent.update((editor) => editor.remove(id)) + yield* agent.transform((editor) => editor.remove(id)) expect(yield* agent.get(id)).toBeUndefined() }), ) @@ -106,7 +102,11 @@ describe("AgentV2", () => { it.effect("does not ambiently opt built-in agents into bash", () => Effect.gen(function* () { const agent = yield* AgentV2.Service - yield* AgentPlugin.Plugin.effect.pipe( + yield* AgentPlugin.Plugin.effect( + host({ + agent: agentHost(agent), + }), + ).pipe( Effect.provideService( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index 38b0c957048..93feeeae3cb 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" -import { Tool } from "@opencode-ai/core/public" +import { Tool } from "@opencode-ai/core/tool/tool" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" -import { PermissionV2 } from "@opencode-ai/core/permission" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { AgentV2 } from "@opencode-ai/core/agent" @@ -9,19 +10,14 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { executeTool, settleTool, toolDefinitions } from "./lib/tool" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { Tools } from "@opencode-ai/core/tool/tools" -import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect" +import { Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect" import { testEffect } from "./lib/effect" -const permission = Layer.mock(PermissionV2.Service, { - assert: () => Effect.void, -}) -const applications = ApplicationTools.layer -const registry = ToolRegistry.layer.pipe( - Layer.provide(permission), - Layer.provide(applications), - Layer.provide(ToolOutputStore.defaultLayer), +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node, ToolRegistry.toolsNode]), [ + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), ) -const it = testEffect(Layer.mergeAll(applications, registry)) const sessionID = SessionV2.ID.make("ses_application_tool") const agent = AgentV2.ID.make("build") diff --git a/packages/core/test/background-job.test.ts b/packages/core/test/background-job.test.ts index 1c4f93f019e..5ad1e061a5b 100644 --- a/packages/core/test/background-job.test.ts +++ b/packages/core/test/background-job.test.ts @@ -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", () => diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 77a18e79aef..c6e9976735b 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,35 +1,53 @@ -import { describe, expect } from "bun:test" -import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" +import { afterAll, describe, expect } from "bun:test" +import { Effect, Fiber, Layer, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" import { Credential } from "@opencode-ai/core/credential" +import { Global } from "@opencode-ai/core/global" +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 { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" import { Policy } from "@opencode-ai/core/policy" -import { Project } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) -const it = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), - ), +// kilocode_change - Credential imports Global.data/auth.json on startup, so without this the suite +// reads the developer's real credential store and its results depend on whether they are logged in. +const dataDirs: string[] = [] +// Each Layer.fresh below rebuilds Credential, which re-imports data/auth.json, so a shared directory +// would carry credentials from one test into the next. Give every layer its own. +const globalLayer = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-catalog-test-")) + dataDirs.push(dir) + return Global.layerWith({ data: dir }) +} +afterAll(() => { + for (const dir of dataDirs) fs.rmSync(dir, { recursive: true, force: true }) +}) +const catalogLayer = AppNodeBuilder.build( + LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]), + [ + [Location.node, locationLayer], + [Global.node, globalLayer()], // kilocode_change + ], ) +const it = testEffect(catalogLayer) describe("CatalogV2", () => { it.effect("publishes an updated event after catalog changes", () => @@ -41,7 +59,7 @@ describe("CatalogV2", () => { .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) expect((yield* Fiber.join(updated)).length).toBe(1) }), @@ -49,42 +67,62 @@ describe("CatalogV2", () => { it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") - const first = { - id: Credential.ID.create(), - integrationID, - label: "First", - value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }), - } - const second = { - id: Credential.ID.create(), - integrationID, - label: "Second", - value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }), - } - let active = first - const layer = Catalog.locationLayer.pipe( - Layer.fresh, - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.sync(() => [active]), - list: () => Effect.sync(() => [active]), - }), - ), + const localCatalogLayer = Layer.fresh( + AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [ + [Location.node, locationLayer], + [Global.node, globalLayer()], // kilocode_change + ]), ) return Effect.gen(function* () { const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + const credentials = yield* Credential.Service + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* credentials.create({ + integrationID, + label: "First", + value: Credential.Key.make({ type: "key", key: "first", metadata: { tenant: "one" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) - expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) - active = second + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) + yield* credentials.create({ + integrationID, + label: "Second", + value: Credential.Key.make({ type: "key", key: "second", metadata: { tenant: "two" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) - expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) - }).pipe(Effect.provide(layer)) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) + }).pipe(Effect.provide(localCatalogLayer)) + }) + + it.effect("derives availability from a provider's integration", () => { + const integrationID = Integration.ID.make("gateway") + const providerID = ProviderV2.ID.make("remote") + const localCatalogLayer = Layer.fresh( + AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [ + [Location.node, locationLayer], + [Global.node, globalLayer()], // kilocode_change + ]), + ) + + return Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {})) + yield* catalog.transform((editor) => + editor.provider.update(providerID, (provider) => { + provider.integrationID = integrationID + }), + ) + expect(yield* catalog.provider.available()).toEqual([]) + + yield* (yield* Credential.Service).create({ + integrationID, + value: Credential.Key.make({ type: "key", key: "secret" }), + }) + + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID]) + }).pipe(Effect.provide(localCatalogLayer)) }) it.effect("projects environment connections without a catalog plugin", () => @@ -99,13 +137,13 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service const providerID = ProviderV2.ID.make("test") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID: Integration.ID.make(providerID), method: { type: "env", names: ["CATALOG_TEST_API_KEY"] }, }), ) - yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {})) + yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID) }), @@ -121,9 +159,7 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -134,7 +170,7 @@ describe("CatalogV2", () => { }), ) - expect((yield* catalog.provider.get(providerID)).api).toEqual({ + expect(required(yield* catalog.provider.get(providerID)).api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://override.example.com", @@ -147,9 +183,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -168,7 +202,7 @@ describe("CatalogV2", () => { }) }) - expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({ + expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ id: modelID, type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -183,9 +217,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -196,7 +228,7 @@ describe("CatalogV2", () => { catalog.model.update(providerID, modelID, () => {}) }) - expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({ + expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ id: modelID, type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -205,106 +237,12 @@ describe("CatalogV2", () => { }), ) - it.effect("runs catalog transform hooks after baseURL is normalized", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const plugin = yield* PluginV2.Service - const providerID = ProviderV2.ID.make("test") - const seen: unknown[] = [] - const transform = yield* catalog.transform() - - yield* plugin.add({ - id: PluginV2.ID.make("test"), - effect: Effect.succeed({ - "catalog.transform": (evt) => - Effect.sync(() => { - const item = evt.provider.get(providerID) - if (!item) return - seen.push(item.provider.api.type) - if (item?.provider.api.type === "aisdk") seen.push(item.provider.api.url) - seen.push(item?.provider.request.body.baseURL) - }), - }), - }) - yield* transform((catalog) => - catalog.provider.update(providerID, (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } - provider.request.body.baseURL = "https://provider.example.com" - }), - ) - - expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined]) - }), - ) - - it.effect("runs catalog transform when a plugin is added", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const plugin = yield* PluginV2.Service - const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => - catalog.provider.update(providerID, (provider) => { - provider.name = "Before" - }), - ) - yield* plugin.add({ - id: PluginV2.ID.make("test-transform"), - effect: Effect.succeed({ - "catalog.transform": (evt) => - Effect.sync(() => - evt.provider.update(providerID, (provider) => { - provider.name = "After" - }), - ), - }), - }) - yield* Effect.yieldNow - - expect((yield* catalog.provider.get(providerID)).name).toBe("After") - }), - ) - - it.effect("ignores plugin additions from another location", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const plugin = yield* PluginV2.Service - let invoked = 0 - - yield* plugin.add({ - id: PluginV2.ID.make("test-transform"), - effect: Effect.succeed({ - "catalog.transform": () => Effect.sync(() => invoked++), - }), - }) - yield* Effect.yieldNow - expect(invoked).toBe(1) - - yield* events.publish( - PluginV2.Event.Added, - { id: PluginV2.ID.make("test-transform") }, - { - location: new Location.Info({ - directory: AbsolutePath.make("other"), - project: { id: Project.ID.global, directory: AbsolutePath.make("other") }, - }), - }, - ) - yield* Effect.yieldNow - - expect(invoked).toBe(1) - }), - ) - it.effect("resolves provider and model request merges", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.request.headers.provider = "provider" provider.request.headers.shared = "provider" @@ -315,16 +253,13 @@ describe("CatalogV2", () => { model.request.headers.shared = "model" model.request.body.model = true model.request.body.request = true - const options = (model.request.options ??= {}) - options.shared = "model" - options.model = true + model.request.body.shared = "model" }) }) - const model = yield* catalog.model.get(providerID, modelID) + const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) - expect(model.request.body).toEqual({ provider: true, model: true, request: true }) - expect(model.request.options).toEqual({ shared: "model", model: true }) + expect(model.request.body).toEqual({ provider: true, model: true, request: true, shared: "model" }) }), ) @@ -332,19 +267,17 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { - model.time.released = DateTime.makeUnsafe(1000) + model.time.released = 1000 }) catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { - model.time.released = DateTime.makeUnsafe(2000) + model.time.released = 2000 }) }) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new") + expect((yield* catalog.model.default())?.id).toMatch("new") }), ) @@ -354,26 +287,26 @@ describe("CatalogV2", () => { const providerID = ProviderV2.ID.make("test") const old = ModelV2.ID.make("old") const newest = ModelV2.ID.make("new") - const transform = yield* catalog.transform() - - const models = (catalog: Catalog.Editor) => { + const models = (catalog: Catalog.Draft) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, old, (model) => { - model.time.released = DateTime.makeUnsafe(1000) + model.time.released = 1000 }) catalog.model.update(providerID, newest, (model) => { - model.time.released = DateTime.makeUnsafe(2000) + model.time.released = 2000 }) } - yield* transform((catalog) => { + let configured = true + yield* catalog.transform((catalog) => { models(catalog) - catalog.model.default.set(providerID, old) + if (configured) catalog.model.default.set(providerID, old) }) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old) + expect((yield* catalog.model.default())?.id).toBe(old) - yield* transform(models) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest) + configured = false + yield* catalog.reload() + expect((yield* catalog.model.default())?.id).toBe(newest) }), ) @@ -384,9 +317,7 @@ describe("CatalogV2", () => { const enabledProvider = ProviderV2.ID.make("enabled") const disabledModel = ModelV2.ID.make("configured") const fallbackModel = ModelV2.ID.make("fallback") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(disabledProvider, (provider) => { provider.disabled = true }) @@ -396,7 +327,7 @@ describe("CatalogV2", () => { catalog.model.default.set(disabledProvider, disabledModel) }) - expect(Option.getOrUndefined(yield* catalog.model.default())).toMatchObject({ + expect(yield* catalog.model.default()).toMatchObject({ providerID: enabledProvider, id: fallbackModel, }) @@ -407,25 +338,23 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) }) - expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") + expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") }), ) @@ -434,17 +363,15 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const policy = yield* Policy.Service const providerID = ProviderV2.ID.make("blocked") - const transform = yield* catalog.transform() - yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) }) expect(yield* catalog.provider.all()).toEqual([]) expect(yield* catalog.model.all()).toEqual([]) - expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none()) + expect(yield* catalog.provider.get(providerID)).toBeUndefined() }), ) }) diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index f2175743e42..3da8523c5b5 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -1,18 +1,18 @@ 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", () => Effect.gen(function* () { const command = yield* CommandV2.Service - const transform = yield* command.transform() - yield* transform((editor) => { + yield* command.transform((editor) => { editor.update("review", (command) => { command.template = "First" command.description = "Review code" @@ -28,7 +28,7 @@ describe("CommandV2", () => { }) expect(yield* command.get("review")).toEqual( - new CommandV2.Info({ + CommandV2.Info.make({ name: "review", template: "Second", description: "Review code", @@ -40,7 +40,7 @@ describe("CommandV2", () => { }), ) expect(yield* command.list()).toEqual([ - new CommandV2.Info({ + CommandV2.Info.make({ name: "review", template: "Second", description: "Review code", diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 79e872f74e2..4a28fb7877b 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -1,17 +1,20 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +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 { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { agentHost, host } from "../plugin/host" -const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node]))) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigAgentPlugin.Plugin", () => { @@ -19,9 +22,7 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* AgentV2.Service const build = AgentV2.ID.make("build") - const defaults = yield* agents.transform() - - yield* defaults((editor) => + yield* agents.transform((editor) => editor.update(build, (agent) => { agent.mode = "primary" agent.permissions.push({ action: "bash", resource: "*", effect: "allow" }) @@ -68,9 +69,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) const buildAgent = yield* agents.get(build) @@ -150,9 +150,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) @@ -177,8 +176,7 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* AgentV2.Service const build = AgentV2.ID.make("build") - const defaults = yield* agents.transform() - yield* defaults((editor) => editor.update(build, () => {})) + yield* agents.transform((editor) => editor.update(build, () => {})) const config = Config.Service.of({ entries: () => @@ -190,9 +188,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) expect(yield* agents.get(build)).toBeUndefined() @@ -251,9 +248,8 @@ Use native v2 fields.`, ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({ diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index f5e4f41af77..d053f37399e 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -1,18 +1,21 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { CommandV2 } from "@opencode-ai/core/command" import { Config } from "@opencode-ai/core/config" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" +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 { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" -const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]))) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigCommandPlugin.Plugin", () => { @@ -41,8 +44,7 @@ Review files`, }) const command = yield* CommandV2.Service - yield* ConfigCommandPlugin.Plugin.effect.pipe( - Effect.provideService(CommandV2.Service, command), + yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe( Effect.provideService( Config.Service, Config.Service.of({ @@ -59,7 +61,7 @@ Review files`, ) expect(yield* command.list()).toEqual([ - new CommandV2.Info({ + CommandV2.Info.make({ name: "review", template: "Review files", description: "File review", @@ -72,8 +74,8 @@ Review files`, variant: ModelV2.VariantID.make("high"), subtask: true, }), - new CommandV2.Info({ name: "empty", template: "" }), - new CommandV2.Info({ name: "nested/docs", template: "Write docs" }), + CommandV2.Info.make({ name: "empty", template: "" }), + CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }), ]) }), ), @@ -83,8 +85,7 @@ Review files`, it.effect("applies a global partial override after project command definitions", () => Effect.gen(function* () { const command = yield* CommandV2.Service - yield* ConfigCommandPlugin.Plugin.effect.pipe( - Effect.provideService(CommandV2.Service, command), + yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe( Effect.provideService( Config.Service, Config.Service.of({ diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 7619b0eda08..d9c3cb77d16 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -5,6 +5,8 @@ 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 { 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" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -25,21 +27,19 @@ function testLayer( projectDirectory = directory, vcs?: Project.Vcs, ) { - return Config.locationLayer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layerWith({ config: globalDirectory })), - Layer.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(projectDirectory), vcs }, - ), - ), + const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory), vcs }, ), ), ) + return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ + [Location.node, locationLayer], + [Global.node, Global.layerWith({ config: globalDirectory })], + ]) } const provider = { @@ -106,7 +106,6 @@ describe("Config", () => { expect(migrated.providers?.bedrock?.api).toEqual({ type: "aisdk", package: "@ai-sdk/amazon-bedrock", - url: undefined, settings: { region: "us-east-1", profile: "dev" }, }) expect(migrated.providers?.bedrock?.request).toEqual({ @@ -201,7 +200,7 @@ describe("Config", () => { ) // kilocode_change end - it.live("loads JSON and JSONC files from lowest to highest priority", () => + it.live("loads opencode JSON and JSONC files from lowest to highest priority", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -210,13 +209,9 @@ describe("Config", () => { Effect.gen(function* () { yield* Effect.promise(() => Promise.all([ - fs.writeFile( - path.join(tmp.path, "config.json"), - JSON.stringify({ $schema: "base", providers: { base: provider } }), - ), fs.writeFile( path.join(tmp.path, "opencode.json"), - JSON.stringify({ $schema: "middle", providers: { middle: provider } }), + JSON.stringify({ $schema: "base", providers: { base: provider } }), ), fs.writeFile( path.join(tmp.path, "opencode.jsonc"), @@ -232,12 +227,12 @@ describe("Config", () => { const config = yield* Config.Service const documents = (yield* config.entries()).filter((entry) => entry.type === "document") - expect(documents).toHaveLength(3) - expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"]) - expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + expect(documents).toHaveLength(2) + expect(documents.map((document) => document.type)).toEqual(["document", "document"]) + expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"]) expect(documents[0]).toBeInstanceOf(Config.Document) - expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json")) - expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) + expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json")) + expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })), @@ -246,7 +241,30 @@ describe("Config", () => { (yield* config.entries()) .filter((entry) => entry.type === "document") .map((document) => document.info.$schema), - ).toEqual(["base", "middle", "last"]) + ).toEqual(["base", "last"]) + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + + // kilocode_change - Kilo keeps loading config.json for released installs (see the names list in config.ts) + it.live("still loads legacy config.json files", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = (yield* config.entries()).filter((entry) => entry.type === "document") + + expect(documents).toHaveLength(1) // kilocode_change }).pipe(Effect.provide(testLayer(tmp.path))) }), ), @@ -338,14 +356,14 @@ describe("Config", () => { }, tool_output: { max_lines: 1000, max_bytes: 32768 }, mcp: { - timeout: 5000, + timeout: { startup: 5000, request: 60000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: 10000, + timeout: { request: 10000 }, }, remote: { type: "remote", @@ -353,6 +371,7 @@ describe("Config", () => { headers: { Authorization: "Bearer token" }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, disabled: true, + timeout: { startup: 15000 }, }, }, }, @@ -423,14 +442,14 @@ describe("Config", () => { }) expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 }) expect(documents[0]?.info.mcp).toEqual({ - timeout: 5000, + timeout: { startup: 5000, request: 60000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: 10000, + timeout: { request: 10000 }, }, remote: { type: "remote", @@ -438,6 +457,7 @@ describe("Config", () => { headers: { Authorization: "Bearer token" }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, disabled: true, + timeout: { startup: 15000 }, }, }, }) @@ -581,11 +601,12 @@ describe("Config", () => { compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 }, experimental: { mcp_timeout: 5000 }, mcp: { - local: { type: "local", command: ["node", "server.js"], enabled: false }, + local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { clientId: "client", callbackPort: 19876 }, + timeout: 20000, }, }, }), @@ -638,9 +659,9 @@ describe("Config", () => { models: { model: { request: { - body: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" }, + body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" }, }, - variants: [{ id: "high", body: { reasoningEffort: "high", reasoningSummary: "auto" } }], + variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }], }, }, }) @@ -663,13 +684,19 @@ describe("Config", () => { buffer: 10000, }) expect(documents[0]?.info.mcp).toMatchObject({ - timeout: 5000, + timeout: { request: 5000 }, servers: { - local: { type: "local", command: ["node", "server.js"], disabled: true }, + local: { + type: "local", + command: ["node", "server.js"], + disabled: true, + timeout: { request: 10000 }, + }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { client_id: "client", callback_port: 19876 }, + timeout: { request: 20000 }, }, }, }) @@ -679,7 +706,7 @@ describe("Config", () => { ), ) - it.live("ignores invalid files while loading valid config values", () => + it.live("ignores an invalid file while loading valid config values", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -688,9 +715,8 @@ describe("Config", () => { Effect.gen(function* () { yield* Effect.promise(() => Promise.all([ - fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })), - fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"), - fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })), + fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })), + fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"), ]), ) return yield* Effect.gen(function* () { @@ -763,7 +789,7 @@ describe("Config", () => { fs.writeFile(path.join(global, "kilo.json"), JSON.stringify({ $schema: "global" })), fs.writeFile(path.join(root, "kilo.json"), JSON.stringify({ $schema: "root" })), fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })), - fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })), + fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })), fs.writeFile(path.join(root, ".kilocode", "opencode.json"), JSON.stringify({ $schema: "root-kilocode" })), fs.writeFile(path.join(root, ".kilo", "kilo.json"), JSON.stringify({ $schema: "root-kilo" })), fs.writeFile( diff --git a/packages/core/test/config/fixtures/plugin/directory-plugin.ts b/packages/core/test/config/fixtures/plugin/directory-plugin.ts new file mode 100644 index 00000000000..8bb25662092 --- /dev/null +++ b/packages/core/test/config/fixtures/plugin/directory-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@kilocode/plugin/v2/promise" + +export default define({ + id: "directory-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("directory", (agent) => { + agent.description = "Loaded from plugin directory" + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts new file mode 100644 index 00000000000..2e134c55e18 --- /dev/null +++ b/packages/core/test/config/plugin.test.ts @@ -0,0 +1,248 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" +import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigExternalPlugin", () => { + it.live("resolves and loads a configured Promise plugin with options", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + const document = path.join(import.meta.dir, "opencode.json") + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: document, + info: decode({ + plugins: [ + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: "Loaded from config" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Loaded from config", + mode: "subagent", + }) + }), + ) + + it.live("loads a configured Effect plugin with options", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: path.join(import.meta.dir, "opencode.json"), + info: decode({ + plugins: [ + { + package: "../plugin/fixtures/config-effect-plugin.ts", + options: { description: "Effect plugin from config" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({ + description: "Effect plugin from config", + mode: "subagent", + }) + }), + ) + + it.live("ignores invalid plugins and continues loading", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: path.join(import.meta.dir, "opencode.json"), + info: decode({ + plugins: [ + "../plugin/fixtures/missing-plugin.ts", + "../plugin/fixtures/invalid-plugin.ts", + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: "Loaded after invalid plugins" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Loaded after invalid plugins", + }) + }), + ) + + it.live("installs and resolves npm plugin packages", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const host = yield* PluginHost.make(plugins) + let installed: string | undefined + const npm = Npm.Service.of({ + add: (spec) => + Effect.sync(() => { + installed = spec + return { + directory: import.meta.dir, + entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + } + }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + plugins: [ + { + package: "example-plugin@1.0.0", + options: { description: "Installed from npm" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Installed from npm", + }) + expect(installed).toBe("example-plugin@1.0.0") + }), + ) + + it.live("loads plugin files from config directories", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Directory({ + type: "directory", + path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "directory")).toMatchObject({ + description: "Loaded from plugin directory", + mode: "subagent", + }) + }), + ) +}) + +const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { + for (let attempt = 0; attempt < 100; attempt++) { + const agent = yield* agents.get(AgentV2.ID.make(id)) + if (agent) return agent + yield* Effect.sleep("10 millis") + } + return yield* Effect.die(`Timed out waiting for agent ${id}`) +}) diff --git a/packages/core/test/config/provider-options.test.ts b/packages/core/test/config/provider-options.test.ts index a407353d097..44a1c0c9db5 100644 --- a/packages/core/test/config/provider-options.test.ts +++ b/packages/core/test/config/provider-options.test.ts @@ -39,8 +39,18 @@ describe("ConfigProviderOptionsV1", () => { body: { store: true }, settings: { timeout: 1000 }, }) - expect(lowerer.request({ reasoningEffort: "high", nestedValue: { camelCase: true } })).toEqual({ - reasoning_effort: "high", + expect( + lowerer.request({ + reasoningEffort: "high", + reasoningSummary: "auto", + reasoning: { encryptedContent: true }, + textVerbosity: "low", + text: { outputFormat: "plain" }, + nestedValue: { camelCase: true }, + }), + ).toEqual({ + reasoning: { encrypted_content: true, effort: "high", summary: "auto" }, + text: { output_format: "plain", verbosity: "low" }, nested_value: { camel_case: true }, }) }) @@ -130,7 +140,10 @@ describe("ConfigProviderOptionsV1", () => { body: { trace: true }, settings: { resourceName: "resource" }, }) - expect(lowerer.request({ reasoningEffort: "high" })).toEqual({ reasoning_effort: "high" }) + expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({ + reasoning: { effort: "high", summary: "auto" }, + text: { verbosity: "low" }, + }) }) test("lowers Amazon Bedrock provider and request options", () => { diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index a2ecc9954b3..605bcd63cf9 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,13 +1,49 @@ import { describe, expect } from "bun:test" -import { Effect, Option, Schema } from "effect" +import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, withEnv } from "../plugin/provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (config: Config.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} function request(headers: Record, variant?: string) { return { @@ -19,11 +55,9 @@ function request(headers: Record, variant?: string) { const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { - it.effect("partitions existing model variant bodies without changing config shape", () => + it.effect("keeps configured model variant bodies unchanged", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -56,21 +90,13 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( - Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), - ), - }) + yield* addPlugin(config) - const model = yield* catalog.model.get(providerID, modelID) + const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants).toMatchObject([ { id: "high", - body: {}, - options: { + body: { reasoningEffort: "high", reasoningSummary: "auto", include: ["reasoning.encrypted_content"], @@ -80,11 +106,9 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) - it.effect("uses the effective provider package across layered config", () => + it.effect("keeps layered model variant bodies unchanged", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -117,20 +141,12 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( - Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), - ), - }) + yield* addPlugin(config) - const model = yield* catalog.model.get(providerID, modelID) + const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants[0]).toMatchObject({ id: "high", - body: {}, - options: { reasoningEffort: "high" }, + body: { reasoningEffort: "high" }, }) }), ) @@ -140,7 +156,6 @@ describe("ConfigProviderPlugin.Plugin", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("custom") const modelID = ModelV2.ID.make("chat") const config = Config.Service.of({ @@ -220,18 +235,11 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( - Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), - ), - }) + yield* addPlugin(config) - const provider = yield* catalog.provider.get(providerID) - const model = yield* catalog.model.get(providerID, modelID) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) + const provider = required(yield* catalog.provider.get(providerID)) + const model = required(yield* catalog.model.get(providerID, modelID)) + expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) expect(provider.name).toBe("Renamed") expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({ type: "env", diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 52b9c0bb666..0dc5e3b4d74 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -9,6 +9,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" const it = testEffect(Layer.empty) const decode = Schema.decodeUnknownSync(Config.Info) @@ -18,16 +19,28 @@ describe("ConfigSkillPlugin.Plugin", () => { Effect.gen(function* () { const directory = AbsolutePath.make("/repo/packages/app") const sources: SkillV2.Source[] = [] - const transform = Effect.fnUntraced(function* () { - return Effect.fnUntraced(function* (update: (editor: SkillV2.Editor) => void) { - update({ - source: (source) => sources.push(source), - list: () => sources, - }) + const transform = Effect.fnUntraced(function* (update: (draft: SkillV2.Draft) => void | Effect.Effect) { + const result = update({ + source: (source) => { + sources.push(source) + }, + list: () => sources, }) + if (Effect.isEffect(result)) yield* result + const dispose = Effect.sync(() => { + sources.length = 0 + }) + yield* Effect.addFinalizer(() => dispose) + return { dispose } }) - yield* ConfigSkillPlugin.Plugin.effect.pipe( + yield* ConfigSkillPlugin.Plugin.effect( + host({ + skill: { transform, reload: () => Effect.void }, + }), + ).pipe( + Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })), + Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), Effect.provideService( Config.Service, Config.Service.of({ @@ -43,34 +56,24 @@ describe("ConfigSkillPlugin.Plugin", () => { ]), }), ), - Effect.provideService(Global.Service, Global.Service.of(Global.make({ home: "/home/test" }))), - Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), - Effect.provideService( - SkillV2.Service, - SkillV2.Service.of({ - transform, - sources: () => Effect.succeed(sources), - list: () => Effect.succeed([]), - }), - ), ) expect(sources).toEqual([ - new SkillV2.DirectorySource({ + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skill")), }), - new SkillV2.DirectorySource({ + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skills")), }), - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), - new SkillV2.DirectorySource({ + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join("/home/test", "shared-skills")), }), - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make("/opt/skills") }), - new SkillV2.UrlSource({ type: "url", url: "https://example.test/skills/" }), + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }), + SkillV2.UrlSource.make({ type: "url", url: "https://example.test/skills/" }), ]) }), ) diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index 1a987534d3b..e6cbf820bb1 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -2,36 +2,36 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Credential } from "@opencode-ai/core/credential" -import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Integration } from "@opencode-ai/core/integration" // kilocode_change start -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Database } from "@opencode-ai/core/database/database" import { Global } from "@opencode-ai/core/global" // kilocode_change end import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" -function layer(directory: string) { - return Credential.layer.pipe( +function localLayer(directory: string) { + return LayerNode.compile(Credential.node, [ + [Database.node, Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)], + [Global.node, Global.layerWith({ data: directory })], + ]).pipe( Layer.fresh, // kilocode_change - rebuild so process-local credentials are re-read - Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)), ) } describe("Credential", () => { it.live("stores, updates, lists, and removes credentials", () => - Effect.acquireRelease( + Effect.acquireUseRelease( Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => + (tmp) => Effect.gen(function* () { const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const created = yield* credentials.create({ integrationID, label: "Work", - value: new Credential.Key({ type: "key", key: "secret" }), + value: Credential.Key.make({ type: "key", key: "secret" }), }) expect(yield* credentials.list(integrationID)).toEqual([created]) @@ -41,14 +41,14 @@ describe("Credential", () => { const replacement = yield* credentials.create({ integrationID, label: "Replacement", - value: new Credential.Key({ type: "key", key: "replacement" }), + value: Credential.Key.make({ type: "key", key: "replacement" }), }) expect(yield* credentials.list(integrationID)).toEqual([replacement]) yield* credentials.remove(replacement.id) expect(yield* credentials.list(integrationID)).toEqual([]) - }).pipe(Effect.provide(layer(tmp.path))), - ), + }).pipe(Effect.provide(localLayer(tmp.path))), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) @@ -99,7 +99,7 @@ describe("Credential", () => { const created = yield* service.create({ integrationID: kilocode, label: "Temporary", - value: new Credential.Key({ type: "key", key: "temporary" }), + value: Credential.Key.make({ type: "key", key: "temporary" }), }) expect(yield* service.list(kilocode)).toEqual([created]) yield* service.update(created.id, { label: "Updated" }) @@ -110,9 +110,9 @@ describe("Credential", () => { delete process.env.KILO_AUTH_CONTENT const stored = yield* Effect.gen(function* () { return yield* (yield* Credential.Service).all() - }).pipe(Effect.provide(layer(tmp.path)), Effect.scoped) + }).pipe(Effect.provide(localLayer(tmp.path)), Effect.scoped) expect(stored).toEqual([]) - }).pipe(Effect.provide(layer(tmp.path))), + }).pipe(Effect.provide(localLayer(tmp.path))), ), ), (previous) => @@ -146,14 +146,7 @@ describe("Credential", () => { }), ), ) - const database = Database.layerFromPath(path.join(tmp.path, "credential.db")).pipe(Layer.fresh) - const global = Global.layerWith({ data: tmp.path }) - const importer = Credential.legacyImportLayer.pipe( - Layer.provide(database), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(global), - ) - const credentials = Credential.layer.pipe(Layer.provide(database), Layer.provideMerge(importer)) + const credentials = localLayer(tmp.path) const result = yield* Effect.gen(function* () { const service = yield* Credential.Service return yield* service.all() @@ -188,14 +181,13 @@ describe("Credential", () => { JSON.stringify({ azure: { type: "api", key: "updated", metadata: { resourceName: "resource" } } }), ), ) - yield* importer.pipe(Layer.build, Effect.scoped) const after = yield* Effect.gen(function* () { const service = yield* Credential.Service return { all: yield* service.all(), azure: yield* service.list(Integration.ID.make("azure")), } - }).pipe(Effect.provide(credentials), Effect.scoped) + }).pipe(Effect.provide(localLayer(tmp.path)), Effect.scoped) expect(after.all).toHaveLength(2) expect(after.azure).toHaveLength(1) expect(after.azure[0]?.value).toMatchObject({ type: "key", key: "updated" }) @@ -209,20 +201,13 @@ describe("Credential", () => { Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ).pipe( - Effect.flatMap((tmp) => { - const database = Database.layerFromPath(path.join(tmp.path, "credential.db")).pipe(Layer.fresh) - const global = Global.layerWith({ data: tmp.path }) - const credentials = Credential.layer.pipe( - Layer.provide(database), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(global), - ) - return Effect.gen(function* () { + Effect.flatMap((tmp) => + Effect.gen(function* () { const service = yield* Credential.Service const integrationID = Integration.ID.make("legacy-reader") yield* service.create({ integrationID, - value: new Credential.Key({ type: "key", key: "first" }), + value: Credential.Key.make({ type: "key", key: "first" }), }) expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "auth.json")).json())).toMatchObject({ "legacy-reader": { type: "api", key: "first" }, @@ -230,13 +215,13 @@ describe("Credential", () => { const replacement = yield* service.create({ integrationID, - value: new Credential.Key({ type: "key", key: "other" }), + value: Credential.Key.make({ type: "key", key: "other" }), }) expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "auth.json")).json())).toMatchObject({ "legacy-reader": { type: "api", key: "other" }, }) - yield* service.update(replacement.id, { value: new Credential.Key({ type: "key", key: "second" }) }) + yield* service.update(replacement.id, { value: Credential.Key.make({ type: "key", key: "second" }) }) expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "auth.json")).json())).toMatchObject({ "legacy-reader": { type: "api", key: "second" }, }) @@ -250,7 +235,7 @@ describe("Credential", () => { yield* Effect.promise(() => Bun.write(file, "{")) yield* service.create({ integrationID: Integration.ID.make("malformed-reader"), - value: new Credential.Key({ type: "key", key: "safe" }), + value: Credential.Key.make({ type: "key", key: "safe" }), }) expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("{") @@ -259,7 +244,7 @@ describe("Credential", () => { ["first-reader", "second-reader"].map((name) => service.create({ integrationID: Integration.ID.make(name), - value: new Credential.Key({ type: "key", key: name }), + value: Credential.Key.make({ type: "key", key: name }), }), ), { concurrency: "unbounded" }, @@ -268,8 +253,8 @@ describe("Credential", () => { "first-reader": { type: "api", key: "first-reader" }, "second-reader": { type: "api", key: "second-reader" }, }) - }).pipe(Effect.provide(credentials), Effect.scoped) - }), + }).pipe(Effect.provide(localLayer(tmp.path)), Effect.scoped), + ), ), ) // kilocode_change end diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index d7126f76e26..b381cc7418a 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -14,6 +14,10 @@ import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/m import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" 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" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -22,6 +26,8 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionV1 } from "@opencode-ai/core/v1/session" import { tmpdir } from "./fixture/tmpdir" const run = (effect: Effect.Effect) => @@ -71,9 +77,9 @@ describe("DatabaseMigration", () => { ).toEqual({ name: "session_context_epoch" }) expect( yield* db.get( - sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`, + sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`, ), - ).toEqual({ name: "agent", dflt_value: "'build'" }) + ).toBeUndefined() expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect( yield* db.all( @@ -226,6 +232,93 @@ describe("DatabaseMigration", () => { ) }) + test("preserves canonical V1 state and restarts its event stream", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`PRAGMA foreign_keys = ON`) + yield* DatabaseMigration.apply(db) + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/project', 1, 1, '[]')`, + ) + yield* db.run( + sql`INSERT INTO workspace (id, type, project_id, time_used) VALUES ('workspace', 'local', 'global', 1)`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, workspace_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'global', 'workspace', 'session', '/project', 'Before', 'test', 1, 1)`, + ) + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('message', 'session', 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('part', 'message', 'session', 1, 1, '{}')`, + ) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`, + ) + yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionInputMigration.id}`) + yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration]) + + const database = Layer.succeed(Database.Service, { db }) + yield* EventV2.Service.use((service) => + service.publish(SessionV1.Event.Updated, { + sessionID: SessionSchema.ID.make("session"), + info: { + id: SessionSchema.ID.make("session"), + slug: "session", + projectID: ProjectV2.ID.global, + directory: "/project", + title: "After", + version: "test", + time: { created: 1, updated: 2 }, + }, + }), + ).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]), + ), + ) + + expect( + yield* db.get(sql` + SELECT + (SELECT title FROM session WHERE id = 'session') AS title, + (SELECT workspace_id FROM session WHERE id = 'session') AS workspaceID, + (SELECT COUNT(*) FROM message WHERE id = 'message') AS messages, + (SELECT COUNT(*) FROM part WHERE id = 'part') AS parts, + (SELECT COUNT(*) FROM workspace) AS workspaces, + (SELECT COUNT(*) FROM session_input) AS sessionInputs, + (SELECT COUNT(*) FROM session_message) AS sessionMessages, + (SELECT COUNT(*) FROM session_context_epoch) AS contextEpochs, + (SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq, + (SELECT type FROM event WHERE aggregate_id = 'session') AS eventType + `), + ).toEqual({ + title: "After", + workspaceID: null, + messages: 1, + parts: 1, + workspaces: 0, + sessionInputs: 0, + sessionMessages: 0, + contextEpochs: 0, + seq: 0, + eventType: "session.updated.1", + }) + }), + ) + }) + test("resets incompatible projected Session messages before adding sequence order", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/effect/cross-spawn-spawner.test.ts b/packages/core/test/effect/cross-spawn-spawner.test.ts index a3f74f671d9..884051d3ef5 100644 --- a/packages/core/test/effect/cross-spawn-spawner.test.ts +++ b/packages/core/test/effect/cross-spawn-spawner.test.ts @@ -6,9 +6,10 @@ import { Effect, Exit, Stream } from "effect" import type * as PlatformError from "effect/PlatformError" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { testEffect } from "../lib/effect" -const live = CrossSpawnSpawner.defaultLayer +const live = LayerNode.compile(CrossSpawnSpawner.node) const fx = testEffect(live) function js(code: string, opts?: ChildProcess.CommandOptions) { diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts new file mode 100644 index 00000000000..366bf746daa --- /dev/null +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -0,0 +1,143 @@ +import { test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/app-node" + +class A extends Context.Service()("test/LayerNodeA") {} +class B extends Context.Service()("test/LayerNodeB") {} +class C extends Context.Service()("test/LayerNodeC") {} +class LayerError { + readonly _tag = "LayerError" +} +class OtherError { + readonly _tag = "OtherError" +} + +const tags = LayerNode.tags({ app: [] }) +const make = tags.make("app") +const build = (root: LayerNode.Node) => LayerNode.compile(root) as Layer.Layer +const aLayer = Layer.succeed(A, A.of({})) +const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) +const cLayer = Layer.effect( + C, + Effect.gen(function* () { + yield* A + yield* B + return C.of({}) + }), +) +const failingA = Layer.effect(A, Effect.fail(new LayerError())) +const a = make({ service: A, layer: aLayer, deps: [] }) +const b = make({ service: B, layer: bLayer, deps: [a] }) +const c = make({ service: C, layer: cLayer, deps: [a, b] }) +const failing = make({ service: A, layer: failingA, deps: [] }) +const dependent = make({ service: B, layer: bLayer, deps: [failing] }) +const inputA = LayerNode.unbound(A, tags.values.app) +const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] }) + +make({ name: "manual-a", layer: aLayer, deps: [] }) + +// @ts-expect-error A node must have a service or name +make({ layer: aLayer, deps: [] }) + +// @ts-expect-error Service and name are mutually exclusive +make({ service: A, name: "a", layer: aLayer, deps: [] }) + +// @ts-expect-error B requires A +make({ service: B, layer: bLayer, deps: [] }) + +// @ts-expect-error C requires A and B +make({ service: C, layer: cLayer, deps: [a] }) + +const closed = build(LayerNode.group([c])) +const closedWithError = build(LayerNode.group([dependent])) +const checkClosed: Layer.Layer = closed +const checkError: Layer.Layer = closedWithError +void checkClosed +void checkError + +LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]]) +LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]]) + +// @ts-expect-error Replacement must provide A +LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]]) + +// @ts-expect-error Node replacement must provide A +const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]]) +void invalidNodeReplacement + +// @ts-expect-error Replacement cannot introduce a new error +LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]]) + +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()("test/TagA") {} +class TagB extends Context.Service()("test/TagB") {} +class TagC extends Context.Service()("test/TagC") {} + +const scopedTags = LayerNode.tags({ request: ["global"], global: [] }) +const request = scopedTags.make("request") +const global = scopedTags.make("global") +const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) +const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) +const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] }) +const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({}))) +const tagCLayer = Layer.effect( + TagC, + Effect.gen(function* () { + yield* TagA + yield* TagB + return TagC.of({}) + }), +) + +request({ service: TagB, layer: tagBLayer, deps: [globalA] }) +request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] }) +request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] }) + +// @ts-expect-error Tag configuration can only reference declared tags +LayerNode.tags({ request: ["missing"], global: [] }) + +// @ts-expect-error An unrelated dependency cannot satisfy TagA +request({ service: TagB, layer: tagBLayer, deps: [requestB] }) + +// @ts-expect-error Providing only TagA leaves TagB missing +request({ service: TagC, layer: tagCLayer, deps: [globalA] }) + +// @ts-expect-error Providing only TagB leaves TagA missing +request({ service: TagC, layer: tagCLayer, deps: [requestB] }) + +// @ts-expect-error Duplicate TagA providers still leave TagB missing +request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] }) + +// @ts-expect-error A group with only TagA still leaves TagB missing +request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] }) + +// @ts-expect-error Global cannot depend on request +global({ service: TagB, layer: tagBLayer, deps: [requestA] }) + +// @ts-expect-error Groups preserve their child tags +global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] }) + +class ScopedA extends Context.Service()("test/ScopedA") {} +class ScopedB extends Context.Service()("test/ScopedB") {} + +const scopedA = Layer.succeed(ScopedA, ScopedA.of({})) +const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({}))) +const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] }) +const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] }) + +makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) + +// @ts-expect-error Global nodes cannot depend on location nodes +makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) + +// @ts-expect-error ScopedB requires ScopedA +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] }) + +test("type exploration compiles", () => {}) diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts new file mode 100644 index 00000000000..b671792c59a --- /dev/null +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +class Value extends Context.Service()("test/LayerNodeValue") {} +class Greeting extends Context.Service()("test/LayerNodeGreeting") {} +class Left extends Context.Service()("test/LayerNodeLeft") {} +class Right extends Context.Service()("test/LayerNodeRight") {} +class Database extends Context.Service()("test/GraphDatabase") {} +class Users extends Context.Service }>()("test/GraphUsers") {} +class App extends Context.Service }>()("test/GraphApp") {} + +const tags = LayerNode.tags({ app: [] }) +const make = tags.make("app") +const build = (root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) => + LayerNode.compile(root, replacements) as Layer.Layer +const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) +const greetingLayer = Layer.effect( + Greeting, + Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })), +) +const value = make({ service: Value, layer: valueLayer, deps: [] }) +const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] }) + +describe("layer node", () => { + test("builds an untagged graph", async () => { + const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] }) + const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] }) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(LayerNode.compile(LayerNode.group([greeting]))), + ) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("builds a dependency graph", async () => { + const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting])))) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("exposes roots but hides transitive dependencies", () => { + const layer = build(LayerNode.group([greeting])) + const check: Layer.Layer = layer + void check + }) + + test("preserves branch-specific implementations across roots", async () => { + const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] }) + const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) + const leftLayer = Layer.effect( + Left, + Effect.map(Value, (item) => Left.of({ value: item.value })), + ) + const rightLayer = Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ) + const left = make({ service: Left, layer: leftLayer, deps: [firstValue] }) + const right = make({ service: Right, layer: rightLayer, deps: [secondValue] }) + const layer = build(LayerNode.group([left, right])) + const program = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toEqual(["first", "second"]) + }) + + test("requires unbound nodes to be replaced before compilation", async () => { + const unbound = LayerNode.unbound(Value, tags.values.app) + const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) + const tree = LayerNode.group([greeting]) + expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue") + const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer + const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("replaces a node with a closed layer", async () => { + const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])), + ) + expect(await Effect.runPromise(program)).toBe("hello simulation") + }) + + test("replaces every use of the same layer", async () => { + const leftLayer = Layer.effect( + Left, + Effect.map(Value, (item) => Left.of({ value: item.value })), + ) + const rightLayer = Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ) + const left = make({ service: Left, layer: leftLayer, deps: [value] }) + const right = make({ service: Right, layer: rightLayer, deps: [value] }) + const replacement = Layer.succeed(Value, Value.of({ value: "replaced" })) + const layer = build(LayerNode.group([left, right]), [[value, replacement]]) + const program = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"]) + }) + + test("does not acquire an unused replacement", async () => { + let acquisitions = 0 + const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] }) + const replacement = Layer.effect( + Left, + Effect.sync(() => { + acquisitions++ + return Left.of({ value: "replacement" }) + }), + ) + await Effect.runPromise( + Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])), + ), + ) + expect(acquisitions).toBe(0) + }) + + test("replaces a node without acquiring its dependencies", async () => { + let acquisitions = 0 + const dependencyLayer = Layer.effect( + Value, + Effect.sync(() => { + acquisitions++ + return Value.of({ value: "dependency" }) + }), + ) + const dependency = make({ service: Value, layer: dependencyLayer, deps: [] }) + const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] }) + const replacement = make({ + service: Greeting, + layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })), + deps: [], + }) + + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([original]), [[original, replacement]])), + ) + + expect(await Effect.runPromise(program)).toBe("replacement") + 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") + const location = tags.make("location") + const database = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "Alice" })), + deps: [], + }) + const users = location({ + service: Users, + layer: Layer.effect( + Users, + Effect.gen(function* () { + const db = yield* Database + return Users.of({ list: Effect.succeed([db.name]) }) + }), + ), + deps: [database], + }) + const app = location({ + service: App, + layer: Layer.effect( + App, + Effect.gen(function* () { + const service = yield* Users + return App.of({ run: service.list }) + }), + ), + deps: [users], + }) + + const result = LayerNode.hoist(LayerNode.group([app]), tags.values.global) + expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) + expect(result.hoisted.dependencies).toEqual([database]) + + const layer = LayerNode.compile(result.node).pipe( + Layer.provide(LayerNode.compile(result.hoisted)), + ) as unknown as Layer.Layer + const program = Effect.gen(function* () { + return yield* (yield* App).run + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(program)).toEqual(["Alice"]) + }) + + test("rejects conflicting hoisted implementations", () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const first = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "first" })), + deps: [], + }) + const second = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "second" })), + deps: [], + }) + const left = location({ + service: Users, + layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), + deps: [first], + }) + const right = location({ + service: App, + layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))), + deps: [second], + }) + + expect(() => LayerNode.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow( + "Tag global has conflicting implementations for test/GraphDatabase", + ) + }) + + test("treats dependency groups as transparent while hoisting", () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const database = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "Alice" })), + deps: [], + }) + const users = location({ + service: Users, + layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), + deps: [LayerNode.group([database])], + }) + const result = LayerNode.hoist(LayerNode.group([users]), tags.values.global) + + expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) + }) +}) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts new file mode 100644 index 00000000000..e9ff2fc149e --- /dev/null +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer, LayerMap, Option } from "effect" +import { Node } 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 { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationError, LocationServices } from "@opencode-ai/core/location-services" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "../../fixture/tmpdir" + +class Value extends Context.Service()("test/TagValue") {} +class Result extends Context.Service()("test/TagResult") {} +class CycleA extends Context.Service()("test/NodeBuildA") {} +class CycleB extends Context.Service()("test/NodeBuildB") {} + +describe("node build", () => { + test("does not build a location service map when the graph does not require it", async () => { + const result = Node.makeGlobalNode({ + service: Result, + layer: Layer.succeed(Result, Result.of({ value: "plain" })), + deps: [], + }) + const layer = AppNodeBuilder.build(result) + const program = Effect.gen(function* () { + expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true) + return (yield* Result).value + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(program)).toBe("plain") + }) + + test("detects cycles through a replaced location service map", async () => { + const a = Node.makeGlobalNode({ + service: CycleA, + layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))), + deps: [LocationServiceMap.node], + }) + const b = Node.makeGlobalNode({ + service: CycleB, + layer: Layer.effect( + CycleB, + Effect.map(CycleA, () => CycleB.of({ directory: AbsolutePath.make(process.cwd()) })), + ), + deps: [a], + }) + const mapLayer = Layer.effect( + LocationServiceMap.Service, + Effect.gen(function* () { + const service = yield* CycleB + return yield* LayerMap.make( + (ref: Location.Ref) => + Layer.succeed( + Location.Service, + Location.Service.of({ + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: Project.ID.global, directory: service.directory }, + }), + ), + { idleTimeToLive: "1 minute" }, + ) + }) as unknown as Effect.Effect, never, CycleB>, + ) + const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] }) + expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow( + "Cycle detected in layer tree", + ) + }) + + test("shares top-level project with location services", async () => { + await using tmp = await tmpdir() + let acquisitions = 0 + const projectLayer = Layer.effect( + Project.Service, + Effect.sync(() => { + acquisitions++ + return Project.Service.of({ + directories: () => Effect.succeed([]), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + commit: () => Effect.void, + }) + }), + ) + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [ + [Project.node, projectLayer], + ]) + const program = Effect.gen(function* () { + yield* Project.Service + const locations = yield* LocationServiceMap.Service + expect(Option.isSome(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true) + return yield* Location.Service.pipe(Effect.provide(locations.get(ref))) + }).pipe(Effect.provide(layer)) + + expect((await Effect.runPromise(program)).directory).toBe(ref.directory) + expect(acquisitions).toBe(1) + }) + + test("returns a composed application layer", async () => { + const value = Node.makeGlobalNode({ + service: Value, + layer: Layer.succeed(Value, Value.of({ value: "value" })), + deps: [], + }) + const result = Node.makeGlobalNode({ + service: Result, + layer: Layer.effect( + Result, + Effect.gen(function* () { + return Result.of({ value: (yield* Value).value }) + }), + ), + deps: [value], + }) + const serviceLayer = AppNodeBuilder.build(result) + const program = Effect.gen(function* () { + expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true) + return (yield* Result).value + }).pipe(Effect.provide(serviceLayer)) + + expect(await Effect.runPromise(program)).toBe("value") + }) +}) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 54aa7b1412b..bd9b26cb8c6 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1,12 +1,17 @@ import { describe, expect } from "bun:test" -import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" +import { Event } from "@opencode-ai/schema/event" +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" import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { V2Schema } from "@opencode-ai/core/v2-schema" import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" @@ -19,11 +24,7 @@ const locationLayer = Layer.succeed( ) // kilocode_change start - keep concurrent tests isolated from process database migrations const database = Database.layerFromPath(":memory:") -const eventLayer = Layer.mergeAll(EventV2.defaultLayer, database) // kilocode_change end -const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer))) -const itWithoutLocation = testEffect(eventLayer) - const Message = EventV2.define({ type: "test.message", schema: { @@ -33,7 +34,7 @@ const Message = EventV2.define({ const SyncMessage = EventV2.define({ type: "test.sync", - sync: { + durable: { version: 1, aggregate: "id", }, @@ -45,7 +46,7 @@ const SyncMessage = EventV2.define({ const SyncSent = EventV2.define({ type: "test.sent", - sync: { + durable: { version: 1, aggregate: "messageID", }, @@ -64,7 +65,7 @@ const GlobalMessage = EventV2.define({ const VersionedMessage = EventV2.define({ type: "test.versioned", - sync: { + durable: { version: 2, aggregate: "id", }, @@ -74,32 +75,25 @@ const VersionedMessage = EventV2.define({ }, }) -const SyncTimestamp = EventV2.define({ - type: "test.timestamp", - sync: { - version: 1, - aggregate: "id", - }, - schema: { - id: Schema.String, - timestamp: V2Schema.DateTimeUtcFromMillis, - }, +const DurableMessage = SessionV1.Event.MessageRemoved +const durableData = (sessionID: Session.ID, text: string) => ({ + sessionID, + messageID: SessionV1.MessageID.ascending(`msg_${text}`), }) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [ + [Database.node, database], // kilocode_change - isolate concurrent event tests + [Location.node, locationLayer], + ]), +) +const itWithoutLocation = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node]), [ + [Database.node, database], // kilocode_change - isolate concurrent event tests + ]), +) + describe("EventV2", () => { - it.effect("derives stable namespaced external IDs", () => - Effect.sync(() => { - const input = { namespace: "opencord.agent-input", key: "input-1" } - - expect(EventV2.ID.fromExternal(input)).toBe(EventV2.ID.fromExternal(input)) - expect(EventV2.ID.fromExternal(input)).toMatch(/^evt_[a-f0-9]{64}$/) - expect(EventV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(EventV2.ID.fromExternal(input)) - expect(EventV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe( - EventV2.ID.fromExternal({ namespace: "a", key: "b:c" }), - ) - }), - ) - it.effect("publishes events with the current location", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -135,30 +129,25 @@ describe("EventV2", () => { const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" }) expect(event.type).toBe("test.versioned") - expect(event.version).toBe(2) + expect(event.durable?.version).toBe(2) }), ) - it.effect("stores definitions in the exported registry", () => - Effect.sync(() => { - expect(EventV2.registry.get(Message.type)).toBe(Message) - }), - ) - - it.effect("keeps the latest sync definition in the registry", () => + it.effect("selects the latest durable definition independent of declaration order", () => Effect.sync(() => { const latest = EventV2.define({ type: "test.out-of-order", - sync: { version: 2, aggregate: "id" }, + durable: { version: 2, aggregate: "id" }, schema: { id: Schema.String }, }) - EventV2.define({ + const historical = EventV2.define({ type: "test.out-of-order", - sync: { version: 1, aggregate: "id" }, + durable: { version: 1, aggregate: "id" }, schema: { id: Schema.String }, }) - expect(EventV2.registry.get("test.out-of-order")).toBe(latest) + expect(Event.latest([latest, historical]).get("test.out-of-order")).toBe(latest) + expect(Event.latest([historical, latest]).get("test.out-of-order")).toBe(latest) }), ) @@ -193,7 +182,7 @@ describe("EventV2", () => { }), ) - it.effect("commits local operational state inside a new synchronized event transaction", () => + it.effect("commits local operational state inside a new durable event transaction", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() @@ -210,7 +199,7 @@ describe("EventV2", () => { }), ) - it.effect("rolls back the synchronized event and projector when the local commit fails", () => + it.effect("rolls back the durable event and projector when the local commit fails", () => Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service @@ -239,7 +228,7 @@ describe("EventV2", () => { const events = yield* EventV2.Service const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit) - expect(String(exit)).toContain("Local commit hooks require a synchronized event") + expect(String(exit)).toContain("Local commit hooks require a durable event") }), ) @@ -293,7 +282,6 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - yield* events.sync(() => Effect.die("sync defect")) yield* events.listen(() => { throw new Error("listener defect") }) @@ -306,7 +294,70 @@ describe("EventV2", () => { const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" }) expect(received).toEqual([SyncMessage.type]) - expect(event.seq).toBeNumber() + expect(event.durable?.seq).toBeNumber() + }), + ) + + it.effect("notifies global listeners only after a durable event is committed", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + const observed = new Array<{ id: string; seq: number }>() + yield* events.listen((event) => + event.type !== SyncMessage.type + ? Effect.void + : db + .select({ id: EventTable.id, seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.id, event.id)) + .get() + .pipe( + Effect.orDie, + Effect.tap((row) => + Effect.sync(() => { + if (row) observed.push(row) + }), + ), + Effect.asVoid, + ), + ) + + const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "committed" }) + if (!event.durable) throw new Error("Expected durable event metadata") + + expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }]) + }), + ) + + it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const consuming = yield* Deferred.make() + const release = yield* Deferred.make() + const slowStream = yield* EventV2.allBounded(events, 1) + const fastStream = yield* EventV2.allBounded(events, 8) + const slow = yield* slowStream.pipe( + Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))), + Effect.forkScoped, + ) + const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) + + yield* events.publish(Message, { text: "one" }) + yield* Deferred.await(consuming) + yield* events.publish(Message, { text: "two" }) + yield* events.publish(Message, { text: "overflow" }) + const last = yield* events.publish(Message, { text: "still delivered" }) + yield* Deferred.succeed(release, undefined) + + const slowExit = yield* Fiber.await(slow) + expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError) + expect(Array.from(yield* Fiber.join(fast))).toEqual([ + expect.objectContaining({ data: { text: "one" } }), + expect.objectContaining({ data: { text: "two" } }), + expect.objectContaining({ data: { text: "overflow" } }), + last, + ]) }), ) @@ -339,49 +390,7 @@ describe("EventV2", () => { }), ) - it.effect("does not synchronize live-only events", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const synchronized = new Array() - const unsubscribe = yield* events.sync((event) => - Effect.sync(() => { - synchronized.push(event.type) - }), - ) - yield* Effect.addFinalizer(() => unsubscribe) - - yield* events.publish(Message, { text: "live only" }) - yield* events.publish(SyncMessage, { id: "one", text: "durable" }) - - expect(synchronized).toEqual([SyncMessage.type]) - }), - ) - - it.effect("synchronizes only after the durable event commits", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const { db } = yield* Database.Service - const synchronized = new Array() - yield* events.sync((event) => - db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.id, event.id)) - .get() - .pipe( - Effect.orDie, - Effect.map((row) => synchronized.push(row !== undefined)), - Effect.asVoid, - ), - ) - - yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" }) - - expect(synchronized).toEqual([true]) - }), - ) - - it.effect("inserts sync event rows on publish", () => + it.effect("inserts durable event rows on publish", () => Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service @@ -401,7 +410,7 @@ describe("EventV2", () => { }), ) - it.effect("increments sync event seq per aggregate", () => + it.effect("increments durable event seq per aggregate", () => Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service @@ -420,22 +429,22 @@ describe("EventV2", () => { }), ) - it.effect("replays durable aggregate events after a cursor and tails new events", () => + it.effect("replays durable aggregate events after a sequence and tails new events", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) - yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "zero")) + yield* events.publish(DurableMessage, durableData(aggregateID, "one")) const fiber = yield* events - .aggregateEvents({ aggregateID, after: EventV2.Cursor.make(0) }) + .durable({ aggregateID, after: 0 }) .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* events.publish(SyncMessage, { id: aggregateID, text: "two" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "two")) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([ - [EventV2.Cursor.make(1), { id: aggregateID, text: "one" }], - [EventV2.Cursor.make(2), { id: aggregateID, text: "two" }], + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ + [1, durableData(aggregateID, "one")], + [2, durableData(aggregateID, "two")], ]) }), ) @@ -443,22 +452,15 @@ describe("EventV2", () => { it.effect("catches durable aggregate events published during replay handoff", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" }) - const fiber = yield* events - .aggregateEvents({ aggregateID }) - .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "zero")) + const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) - yield* events.publish(SyncMessage, { id: aggregateID, text: "one" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "one")) - expect( - Array.from(yield* Fiber.join(fiber)).map((event) => [ - event.cursor, - (event.event.data as { text: string }).text, - ]), - ).toEqual([ - [EventV2.Cursor.make(0), "zero"], - [EventV2.Cursor.make(1), "one"], + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ + [0, durableData(aggregateID, "zero")], + [1, durableData(aggregateID, "one")], ]) }), ) @@ -468,52 +470,46 @@ describe("EventV2", () => { const readStarted = yield* Deferred.make() const continueRead = yield* Deferred.make() let pause = true - const database = Database.layerFromPath(":memory:") const eventLayer = EventV2.layerWith({ beforeAggregateRead: () => pause ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) : Effect.void, - }).pipe(Layer.provide(database)) + }).pipe(Layer.provide(LayerNode.compile(Database.node))) yield* Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - const fiber = yield* events - .aggregateEvents({ aggregateID }) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const aggregateID = Session.ID.create() + const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Deferred.await(readStarted) pause = false - yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "during handoff")) yield* Deferred.succeed(continueRead, undefined) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([ - [EventV2.Cursor.make(0), { id: aggregateID, text: "during handoff" }], + 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, eventLayer))) + }).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer))) }), ) it.effect("coalesces durable aggregate wakes while draining every committed event", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const count = 64 const fiber = yield* events - .aggregateEvents({ aggregateID }) + .durable({ aggregateID }) .pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow for (let index = 0; index < count; index++) { - yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) }) + yield* events.publish(DurableMessage, durableData(aggregateID, String(index))) } - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual( - Array.from({ length: count }, (_, index) => [ - EventV2.Cursor.make(index), - { id: aggregateID, text: String(index) }, - ]), + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual( + Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]), ) }), ) @@ -521,16 +517,14 @@ describe("EventV2", () => { it.effect("omits live-only events from durable aggregate streams", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - const fiber = yield* events - .aggregateEvents({ aggregateID }) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const aggregateID = Session.ID.create() + const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* events.publish(Message, { text: "live only" }) - yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "durable")) - expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.event.type)).toEqual([SyncMessage.type]) + expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type]) }), ) @@ -553,27 +547,27 @@ describe("EventV2", () => { }), ) - it.effect("replays sync events through projectors", () => + it.effect("replays durable events through projectors", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - yield* events.project(SyncMessage, (event) => + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), ) - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "hello" }, + data: durableData(aggregateID, "hello"), }) - expect(received[0]?.type).toBe(SyncMessage.type) - expect(received[0]?.data).toEqual({ id: aggregateID, text: "hello" }) + expect(received[0]?.type).toBe(DurableMessage.type) + expect(received[0]?.data).toEqual(durableData(aggregateID, "hello")) }), ) @@ -581,14 +575,14 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "replayed" }, + data: durableData(aggregateID, "replayed"), }) const rows = yield* db .select() @@ -608,11 +602,11 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const envelopeAggregateID = EventV2.ID.create() - const payloadAggregateID = EventV2.ID.create() + const envelopeAggregateID = Session.ID.create() + const payloadAggregateID = Session.ID.create() const received = new Array() - yield* events.publish(SyncMessage, { id: payloadAggregateID, text: "seed" }) - yield* events.project(SyncMessage, (event) => + yield* events.publish(DurableMessage, durableData(payloadAggregateID, "seed")) + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), @@ -621,10 +615,10 @@ describe("EventV2", () => { const exit = yield* events .replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID: envelopeAggregateID, - data: { id: payloadAggregateID, text: "replayed" }, + data: durableData(payloadAggregateID, "replayed"), }) .pipe(Effect.exit) const rows = yield* db @@ -650,22 +644,22 @@ describe("EventV2", () => { it.effect("replay defects on sequence mismatch", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "first" }, + data: durableData(aggregateID, "first"), }) const exit = yield* events .replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 5, aggregateID, - data: { id: aggregateID, text: "bad" }, + data: durableData(aggregateID, "bad"), }) .pipe(Effect.exit) @@ -676,9 +670,9 @@ describe("EventV2", () => { it.effect("replay decodes synchronized transformed values before projection", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() - const received = new Array() - yield* events.project(SyncTimestamp, (event) => + const aggregateID = Session.ID.create() + const received = new Array() + yield* events.project(SessionEvent.ContextUpdated, (event) => Effect.sync(() => { received.push(event) }), @@ -686,10 +680,10 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncTimestamp.type, 1), + type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, timestamp: 0 }, + data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" }, }) expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0)) @@ -709,28 +703,28 @@ describe("EventV2", () => { }) .pipe(Effect.exit) - expect(String(exit)).toContain("Unknown sync event type") + expect(String(exit)).toContain("Unknown durable event type") }), ) it.effect("replayAll validates contiguous aggregate events", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const source = yield* events.replayAll([ { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "one" }, + data: durableData(aggregateID, "one"), }, { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "two" }, + data: durableData(aggregateID, "two"), }, ]) @@ -742,38 +736,38 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const one = yield* events.replayAll([ { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "one" }, + data: durableData(aggregateID, "one"), }, { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "two" }, + data: durableData(aggregateID, "two"), }, ]) const two = yield* events.replayAll([ { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, - data: { id: aggregateID, text: "three" }, + data: durableData(aggregateID, "three"), }, { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 3, aggregateID, - data: { id: aggregateID, text: "four" }, + data: durableData(aggregateID, "four"), }, ]) const rows = yield* db @@ -793,10 +787,10 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "seed")) yield* events.claim(aggregateID, "owner-a") - yield* events.project(SyncMessage, (event) => + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), @@ -805,10 +799,10 @@ describe("EventV2", () => { yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "ignored" }, + data: durableData(aggregateID, "ignored"), }, { ownerID: "owner-b" }, ) @@ -820,14 +814,14 @@ describe("EventV2", () => { it.effect("strict owner fences exact replay", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const id = EventV2.ID.create() const replayed = { id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "owned" }, + data: durableData(aggregateID, "owned"), } yield* events.replay(replayed, { ownerID: "owner-a" }) @@ -841,12 +835,12 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() - const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "owned" }) + const aggregateID = Session.ID.create() + const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned")) const replayed = { id: published.id, - type: EventV2.versionedType(SyncMessage.type, 1), - seq: published.seq!, + type: EventV2.versionedType(DurableMessage.type, 1), + seq: published.durable!.seq, aggregateID, data: published.data, } @@ -862,7 +856,7 @@ describe("EventV2", () => { expect(row?.ownerID).toBe("owner-a") const exit = yield* events .replay( - { ...replayed, id: EventV2.ID.create(), seq: 1, data: { id: aggregateID, text: "conflict" } }, + { ...replayed, id: EventV2.ID.create(), seq: 1, data: durableData(aggregateID, "conflict") }, { ownerID: "owner-b", strictOwner: true }, ) .pipe(Effect.exit) @@ -874,15 +868,15 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "owned" }, + data: durableData(aggregateID, "owned"), }, { ownerID: "owner-1" }, ) @@ -901,26 +895,26 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "local" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "local")) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "claimed" }, + data: durableData(aggregateID, "claimed"), }, { ownerID: "owner-1" }, ) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 2, aggregateID, - data: { id: aggregateID, text: "fenced" }, + data: durableData(aggregateID, "fenced"), }, { ownerID: "owner-2" }, ) @@ -945,14 +939,14 @@ describe("EventV2", () => { it.effect("strict replay rejects an owner conflict instead of silently skipping it", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "claimed" }, + data: durableData(aggregateID, "claimed"), }, { ownerID: "owner-1" }, ) @@ -961,10 +955,10 @@ describe("EventV2", () => { .replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "conflict" }, + data: durableData(aggregateID, "conflict"), }, { ownerID: "owner-2", strictOwner: true }, ) @@ -978,20 +972,20 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() yield* events.listen((event) => Effect.sync(() => received.push(event))) const replayed = { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "replayed" }, + data: durableData(aggregateID, "replayed"), } yield* events.replay(replayed, { publish: true }) yield* events.replay(replayed, { publish: true }) - expect(received).toMatchObject([{ id: replayed.id, seq: 0, data: replayed.data }]) + expect(received).toMatchObject([{ id: replayed.id, durable: { seq: 0, version: 1 }, data: replayed.data }]) }), ) @@ -999,19 +993,19 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const replayed = { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "original" }, + data: durableData(aggregateID, "original"), } yield* events.listen((event) => Effect.sync(() => received.push(event))) yield* events.replay(replayed, { publish: true }) const exit = yield* events - .replay({ ...replayed, data: { id: aggregateID, text: "divergent" } }, { publish: true }) + .replay({ ...replayed, data: durableData(aggregateID, "divergent") }, { publish: true }) .pipe(Effect.exit) expect(String(exit)).toContain("Replay diverged") @@ -1022,23 +1016,23 @@ describe("EventV2", () => { it.effect("rejects an event ID reused at another aggregate position", () => Effect.gen(function* () { const events = yield* EventV2.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const id = EventV2.ID.create() yield* events.replay({ id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "first" }, + data: durableData(aggregateID, "first"), }) const exit = yield* events .replay({ id, - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "second" }, + data: durableData(aggregateID, "second"), }) .pipe(Effect.exit) @@ -1050,27 +1044,27 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const { db } = yield* Database.Service - const aggregateID = EventV2.ID.create() + const aggregateID = Session.ID.create() const received = new Array() yield* events.listen((event) => Effect.sync(() => received.push(event))) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "first" }, + data: durableData(aggregateID, "first"), }, { ownerID: "owner-1" }, ) yield* events.replay( { id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 1, aggregateID, - data: { id: aggregateID, text: "ignored" }, + data: durableData(aggregateID, "ignored"), }, { ownerID: "owner-2", publish: true }, ) @@ -1113,14 +1107,14 @@ describe("EventV2", () => { }), ) - it.effect("remove clears sync event sequence", () => + it.effect("remove clears durable event sequence", () => Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() - const aggregateID = EventV2.ID.create() - yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" }) + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "seed")) yield* events.remove(aggregateID) - yield* events.project(SyncMessage, (event) => + yield* events.project(DurableMessage, (event) => Effect.sync(() => { received.push(event) }), @@ -1128,13 +1122,13 @@ describe("EventV2", () => { yield* events.replay({ id: EventV2.ID.create(), - type: EventV2.versionedType(SyncMessage.type, 1), + type: EventV2.versionedType(DurableMessage.type, 1), seq: 0, aggregateID, - data: { id: aggregateID, text: "replayed" }, + data: durableData(aggregateID, "replayed"), }) - expect(received[0]?.data).toEqual({ id: aggregateID, text: "replayed" }) + expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed")) }), ) }) diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index ba12d7c13cb..bcd6ce97ed8 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -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(f: (directory: string) => Effect.Effect) { @@ -359,5 +364,5 @@ function instrumentWrites(run: (write: Effect.Effect, target: string run(filesystem.writeFileString(target, content, options), target), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index 10f61d8a97f..fdce1b44764 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -1,11 +1,12 @@ import { describe, test, expect } from "bun:test" -import { Effect, Layer, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" +import { Effect, FileSystem } from "effect" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import path from "path" -const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) +const live = LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])) const { effect: it } = testEffect(live) describe("FSUtil", () => { diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index fd77f417852..769786d9ed3 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -2,12 +2,13 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" -const it = testEffect(Ripgrep.defaultLayer) +const it = testEffect(LayerNode.compile(Ripgrep.node)) const withTmp = (f: (directory: AbsolutePath) => Effect.Effect) => Effect.acquireRelease( diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index d484c584fb6..09c22daf69c 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -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" @@ -21,7 +22,7 @@ const describeWatcher = 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, @@ -43,12 +44,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)), ) } @@ -199,7 +198,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", () => diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index 3b3f74711d4..7e42e80098f 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -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)) diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 00b3ffbd13f..40d8ed9dc36 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -1,6 +1,8 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Effect, Layer } from "effect" +import { tmpdir } from "./tmpdir" export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { return { @@ -10,3 +12,15 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute vcs: input.vcs, } satisfies Location.Interface } + +export const tempLocationLayer = Layer.unwrap( + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.map((tmp) => { + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + return Layer.succeed(Location.Service, Location.Service.of(location(ref))) + }), + ), +) diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index e09e3e1a5d4..f77e32fd8ba 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -3,27 +3,30 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import { Effect } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Git } from "@opencode-ai/core/git" -import { AbsolutePath } from "@opencode-ai/core/schema" +import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { branch, commit, gitRemote } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect(Git.defaultLayer) +const it = testEffect(LayerNode.compile(Git.node)) describe("Git", () => { it.live("clones a remote and reads checkout metadata", () => withRemote((fixture) => Effect.gen(function* () { const git = yield* Git.Service - const target = path.join(fixture.root, "checkout") - const result = yield* git.clone({ remote: fixture.remote, target }) + const target = AbsolutePath.make(path.join(fixture.root, "checkout")) + const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target }) - expect(result.exitCode).toBe(0) - expect(yield* git.origin(target)).toBe(fixture.remote) - expect(yield* git.head(target)).toBeString() - expect(yield* git.branch(target)).toBe("main") - expect(yield* git.remoteHead(target)).toBe("origin/main") + expect(yield* git.remote.get(repository)).toBe(fixture.remote) + expect(yield* git.history.head(repository)).toBeString() + expect(yield* git.history.branch(repository)).toBe("main") + expect(yield* git.history.defaultRemoteBranch(repository)).toBe("main") + expect(repository.worktree).toBe(target) + expect(repository.gitDirectory).toBe(AbsolutePath.make(path.join(target, ".git"))) + expect(repository.commonDirectory).toBe(repository.gitDirectory) expect(yield* read(path.join(target, "README.md"))).toBe("one\n") }), ), @@ -33,19 +36,19 @@ describe("Git", () => { withRemote((fixture) => Effect.gen(function* () { const git = yield* Git.Service - const target = path.join(fixture.root, "checkout") - yield* git.clone({ remote: fixture.remote, target }) + const target = AbsolutePath.make(path.join(fixture.root, "checkout")) + const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target }) yield* Effect.promise(() => commit(fixture.source, "two\n", "second")) - expect((yield* git.fetch(target)).exitCode).toBe(0) - expect((yield* git.reset(target, "origin/main")).exitCode).toBe(0) + yield* git.sync.fetchRemotes(repository) + yield* git.sync.resetHard(repository, "origin/main") expect(yield* read(path.join(target, "README.md"))).toBe("two\n") yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n")) - expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0) - expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0) - expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0) - expect(yield* git.branch(target)).toBe("feature/docs") + yield* git.sync.fetchBranch(repository, { branch: "feature/docs" }) + yield* git.sync.checkoutRemoteBranch(repository, { branch: "feature/docs" }) + yield* git.sync.resetHard(repository, "origin/feature/docs") + expect(yield* git.history.branch(repository)).toBe("feature/docs") expect(yield* read(path.join(target, "README.md"))).toBe("feature\n") }), ), @@ -90,17 +93,72 @@ describe("Git worktrees", () => { Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore), ) const git = yield* Git.Service - const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) } + const repo = yield* git.repo.discover(directory) + if (!repo) throw new Error("Repository not found") - yield* git.worktreeCreate({ repo, directory: worktree }) + yield* git.worktree.create({ repository: repo, directory: worktree }) - expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true) - const linked = yield* git.find(worktree) - expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree)))) - expect(linked?.store).toBe(repo.store) + expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(true) + const linked = yield* git.repo.discover(worktree) + expect(linked?.worktree).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree)))) + expect(linked?.commonDirectory).toBe(repo.commonDirectory) + expect(linked?.gitDirectory).not.toBe(repo.gitDirectory) if (!linked) throw new Error("Linked worktree not found") - yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false }) - expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false) + yield* git.worktree.remove({ repository: linked, directory: worktree, force: false }) + expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(false) + }), + ) +}) + +describe("Git trees", () => { + it.live("captures, compares, previews, and restores scoped trees", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(async () => { + await initRepo(root.path) + await fs.mkdir(path.join(root.path, "scope")) + await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "one\n") + await fs.writeFile(path.join(root.path, "outside.txt"), "outside\n") + await $`git add .`.cwd(root.path).quiet() + await $`git commit -m initial`.cwd(root.path).quiet() + }) + const git = yield* Git.Service + const source = yield* git.repo.discover(AbsolutePath.make(root.path)) + if (!source) throw new Error("Repository not found") + const storage = AbsolutePath.make(path.join(root.path, ".snapshot")) + const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source }) + yield* git.index.refresh({ repository, scope: RelativePath.make("scope") }) + const before = yield* git.tree.write(repository) + + yield* Effect.promise(async () => { + await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "two\n") + await fs.writeFile(path.join(root.path, "scope", "added.txt"), "added\n") + await fs.writeFile(path.join(root.path, "outside.txt"), "changed outside\n") + }) + yield* git.index.refresh({ repository, scope: RelativePath.make("scope") }) + const after = yield* git.tree.write(repository) + + expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([ + RelativePath.make("scope/added.txt"), + RelativePath.make("scope/tracked.txt"), + ]) + const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 }) + expect(diffs.map((item) => [item.path, item.status])).toEqual([ + [RelativePath.make("scope/added.txt"), "added"], + [RelativePath.make("scope/tracked.txt"), "modified"], + ]) + + const files = new Map([[RelativePath.make("scope/tracked.txt"), before]]) + const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 }) + expect(preview).toHaveLength(1) + expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + yield* git.tree.restore({ repository, files }) + expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n") + expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n") + expect(yield* read(path.join(root.path, "outside.txt"))).toBe("changed outside\n") }), ) }) diff --git a/packages/core/test/github-copilot/openai-responses-language-model.test.ts b/packages/core/test/github-copilot/openai-responses-language-model.test.ts new file mode 100644 index 00000000000..ab047d04ebe --- /dev/null +++ b/packages/core/test/github-copilot/openai-responses-language-model.test.ts @@ -0,0 +1,206 @@ +import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model" +import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input" +import { describe, test, expect, mock } from "bun:test" +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" + +const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] + +function createMockFetch(body: unknown) { + return mock( + async () => new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }), + ) +} + +function createModel(fetchFn: ReturnType) { + return new OpenAIResponsesLanguageModel("test-model", { + provider: "copilot", + url: () => "https://api.test.com/responses", + headers: () => ({ Authorization: "Bearer test-token" }), + fetch: fetchFn as any, + }) +} + +// GitHub Copilot's Responses model echoes item metadata (itemId, reasoningEncryptedContent, +// responseId, ...) under the "copilot" providerOptions/providerMetadata namespace, matching the +// namespace request options already use. It used to echo this metadata under "openai" (a leftover +// from forking the OpenAI Responses model), which left it unreachable by anything reading the +// "copilot" namespace and let stale itemIds slip past stripping meant for that namespace. +describe("doGenerate", () => { + test("attaches item metadata under the copilot namespace, not openai", async () => { + const mockFetch = createMockFetch({ + id: "resp_1", + created_at: 0, + model: "gpt-5.5", + output: [ + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "thinking..." }], + }, + { + type: "message", + role: "assistant", + id: "msg_1", + content: [{ type: "output_text", text: "Hello there", annotations: [] }], + }, + { + type: "function_call", + call_id: "call_1", + name: "bash", + arguments: "{}", + id: "fc_1", + }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }) + const model = createModel(mockFetch) + + const { content, providerMetadata } = await model.doGenerate({ + prompt: TEST_PROMPT, + includeRawChunks: false, + } as any) + + const reasoning = content.find((part: any) => part.type === "reasoning") as any + expect(reasoning.providerMetadata?.copilot?.itemId).toBe("rs_1") + expect(reasoning.providerMetadata?.copilot?.reasoningEncryptedContent).toBe("enc_1") + expect(reasoning.providerMetadata?.openai).toBeUndefined() + + const text = content.find((part: any) => part.type === "text") as any + expect(text.providerMetadata?.copilot?.itemId).toBe("msg_1") + expect(text.providerMetadata?.openai).toBeUndefined() + + const toolCall = content.find((part: any) => part.type === "tool-call") as any + expect(toolCall.providerMetadata?.copilot?.itemId).toBe("fc_1") + expect(toolCall.providerMetadata?.openai).toBeUndefined() + + expect(providerMetadata?.copilot?.responseId).toBe("resp_1") + expect(providerMetadata?.openai).toBeUndefined() + }) +}) + +describe("convertToOpenAIResponsesInput", () => { + test("echoes a stale tool-call itemId from the copilot namespace as the function_call id", async () => { + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: { copilot: { itemId: "fc_999" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(input).toEqual([ + { + type: "function_call", + call_id: "call_1", + name: "bash", + arguments: JSON.stringify({ command: "ls" }), + id: "fc_999", + }, + ]) + }) + + test("omits the function_call id once the stale copilot itemId has been stripped", async () => { + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: {}, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect((input[0] as any).id).toBeUndefined() + }) + + test("preserves reasoning items keyed by the copilot namespace instead of dropping them", async () => { + const { input, warnings } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { copilot: { itemId: "rs_1", reasoningEncryptedContent: "enc_1" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(warnings).toEqual([]) + expect(input).toEqual([ + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "thinking..." }], + }, + ]) + }) + + test("drops reasoning items with no copilot itemId and warns, as before", async () => { + const { input, warnings } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [{ type: "reasoning", text: "thinking...", providerOptions: {} }], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(input).toEqual([]) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toMatchObject({ + message: expect.stringContaining("Non-OpenAI reasoning parts are not supported"), + }) + }) + + test("reads imageDetail from the copilot namespace on user file parts", async () => { + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "user", + content: [ + { + type: "file", + mediaType: "image/png", + data: "aGVsbG8=", + providerOptions: { copilot: { imageDetail: "high" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect((input[0] as any).content[0].detail).toBe("high") + }) +}) diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index ae182faccfe..a6699d219a9 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -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 + filesystemLayer?: Layer.Layer +}) => + 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") }), - ), - ), + }), ), ) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index ca4362c6058..09dc2ad0709 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -1,47 +1,39 @@ -import { describe, expect } from "bun:test" -import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" +import { afterAll, beforeAll, describe, expect } from "bun:test" +import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect" import * as TestClock from "effect/testing/TestClock" -import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" +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 { it } from "./lib/effect" +import { Integration } from "@opencode-ai/core/integration" +import { Global } from "@opencode-ai/core/global" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { testEffect } from "./lib/effect" -const layer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - list: () => Effect.succeed([]), - }), - ), +// kilocode_change start - Kilo dual-writes stored credentials into Global.data/auth.json and re-imports +// them on every startup. testEffect rebuilds the layer per test, so without isolation one test's +// credentials reappear in the next as "Imported" (and the real developer store gets written to). +// KILO_AUTH_CONTENT is Kilo's process-local credential mode: it skips the auth.json dual-write. +// Set it around this file only. bun shares one process across test files, so setting it at module +// scope leaks into credential.test.ts, whose auth.json cases then read "{}" and see no credentials. +const directory = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-integration-test-")) +let previous: string | undefined +beforeAll(() => { + previous = process.env.KILO_AUTH_CONTENT + process.env.KILO_AUTH_CONTENT ??= "{}" +}) +afterAll(() => { + if (previous === undefined) delete process.env.KILO_AUTH_CONTENT + else process.env.KILO_AUTH_CONTENT = previous +}) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node]), [ + [Global.node, Global.layerWith({ data: directory })], + ]), ) - -function connectionLayer( - created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }>, -) { - return Integration.locationLayer.pipe( - Layer.provideMerge(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: (input) => - Effect.sync(() => { - created.push(input) - return new Credential.Stored({ - id: Credential.ID.create(), - integrationID: input.integrationID, - label: input.label ?? "default", - value: input.value, - }) - }), - list: () => Effect.succeed([]), - }), - ), - ) -} +// kilocode_change end describe("Integration", () => { it.effect("registers integrations through the editor", () => @@ -51,7 +43,7 @@ describe("Integration", () => { const openai = Integration.ID.make("openai") yield* integrations - .update((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI"))) + .transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI"))) .pipe(Scope.provide(scope)) expect(yield* integrations.get(openai)).toEqual( new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }), @@ -59,7 +51,7 @@ describe("Integration", () => { yield* Scope.close(scope, Exit.void) expect(yield* integrations.get(openai)).toBeUndefined() - }).pipe(Effect.provide(layer)), + }), ) it.effect("reveals the previous registration when an override closes", () => @@ -70,17 +62,17 @@ describe("Integration", () => { const second = yield* Scope.fork(yield* Scope.Scope) yield* integrations - .update((editor) => editor.update(id, (integration) => (integration.name = "OpenAI"))) + .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI"))) .pipe(Scope.provide(first)) yield* integrations - .update((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override"))) + .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override"))) .pipe(Scope.provide(second)) expect((yield* integrations.get(id))?.name).toBe("OpenAI Override") yield* Scope.close(second, Exit.void) expect((yield* integrations.get(id))?.name).toBe("OpenAI") expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id]) - }).pipe(Effect.provide(layer)), + }), ) it.effect("registers and overrides methods independently", () => @@ -99,7 +91,7 @@ describe("Integration", () => { }) yield* integrations - .update((editor) => + .transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -108,7 +100,7 @@ describe("Integration", () => { ) .pipe(Scope.provide(first)) yield* integrations - .update((editor) => { + .transform((editor) => { expect(editor.get(integrationID)).toEqual({ id: integrationID, name: "openai" }) expect(editor.list()).toEqual([{ id: integrationID, name: "openai" }]) expect(editor.method.list(integrationID)).toEqual([ @@ -128,20 +120,16 @@ describe("Integration", () => { yield* Scope.close(second, Exit.void) expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" }) expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })]) - }).pipe(Effect.provide(layer)), + }), ) - it.effect("connects with a key and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("connects with a key and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { type: "key", label: "API key" }, @@ -158,28 +146,24 @@ describe("Integration", () => { label: "Work", }) - expect(created).toEqual([ - { + expect(yield* credentials.list(integrationID)).toEqual([ + expect.objectContaining({ integrationID, label: "Work", - value: new Credential.Key({ type: "key", key: "secret" }), - }, + value: Credential.Key.make({ type: "key", key: "secret" }), + }), ]) expect((yield* Fiber.join(updated)).length).toBe(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + }), + ) - it.effect("completes code OAuth once and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes code OAuth once and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -190,7 +174,7 @@ describe("Integration", () => { instructions: "Paste the code", callback: (code: string) => Effect.succeed( - new Credential.OAuth({ + Credential.OAuth.make({ type: "oauth", methodID, access: "access", @@ -212,33 +196,31 @@ describe("Integration", () => { expect(attempt.mode).toBe("code") yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }) - expect(created[0]).toEqual({ - integrationID, - label: "Personal", - value: new Credential.OAuth({ - type: "oauth", - methodID, - access: "access", - refresh: "refresh", - expires: 1, - metadata: { code: "1234" }, + expect((yield* credentials.list(integrationID))[0]).toEqual( + expect.objectContaining({ + integrationID, + label: "Personal", + value: Credential.OAuth.make({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + metadata: { code: "1234" }, + }), }), - }) - }).pipe(Effect.provide(connectionLayer(created))) - }) + ) + }), + ) - it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") let closed = false - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -261,21 +243,17 @@ describe("Integration", () => { expect(closed).toBe(false) yield* integrations.attempt.cancel(attempt.attemptID) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) - it.effect("completes auto OAuth in the background", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes auto OAuth in the background", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Browser" }, @@ -285,7 +263,7 @@ describe("Integration", () => { url: "https://example.com/authorize", instructions: "Sign in", callback: Effect.succeed( - new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), + Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), ), }), }), @@ -297,22 +275,18 @@ describe("Integration", () => { status: "complete", time: attempt.time, }) - expect(created).toHaveLength(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toHaveLength(1) + }), + ) - it.effect("expires abandoned OAuth attempts", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("expires abandoned OAuth attempts", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") let closed = false - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Browser" }, @@ -337,34 +311,12 @@ describe("Integration", () => { time: attempt.time, }) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) it.effect("projects credential and env connections", () => { const integrationID = Integration.ID.make("acme") - const rows = [ - { - id: Credential.ID.create(), - integrationID, - label: "Work", - value: new Credential.Key({ type: "key", key: "a" }), - }, - { - id: Credential.ID.create(), - integrationID, - label: "Personal", - value: new Credential.Key({ type: "key", key: "b" }), - }, - ] - const projectionLayer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - list: () => Effect.succeed(rows.map((row) => new Credential.Stored(row))), - }), - ), - ) return Effect.acquireUseRelease( Effect.sync(() => { const previous = process.env.INTEGRATION_TEST_ACME_KEY @@ -375,7 +327,8 @@ describe("Integration", () => { () => Effect.gen(function* () { const integrations = yield* Integration.Service - yield* integrations.update((editor) => + const credentials = yield* Credential.Service + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { @@ -384,23 +337,33 @@ describe("Integration", () => { }, }), ) + const work = yield* credentials.create({ + integrationID, + label: "Work", + value: Credential.Key.make({ type: "key", key: "a" }), + }) + const personal = yield* credentials.create({ + integrationID, + label: "Personal", + value: Credential.Key.make({ type: "key", key: "b" }), + }) // Stored credentials and detected env vars appear as connections. expect((yield* integrations.get(integrationID))?.connections).toEqual([ - { type: "credential", id: rows[0]!.id, label: "Work" }, { type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }, { type: "env", name: "INTEGRATION_TEST_ACME_KEY" }, ]) - expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({ + expect(yield* integrations.connection.active(integrationID)).toEqual({ type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }) - }).pipe(Effect.provide(projectionLayer)), + expect(work.id).not.toBe(personal.id) + }), (previous) => Effect.sync(() => { if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY diff --git a/packages/core/test/kilocode/account-auth-v2-migration.test.ts b/packages/core/test/kilocode/account-auth-v2-migration.test.ts index cabfb758c5c..d007f31970b 100644 --- a/packages/core/test/kilocode/account-auth-v2-migration.test.ts +++ b/packages/core/test/kilocode/account-auth-v2-migration.test.ts @@ -2,7 +2,7 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { eq } from "drizzle-orm" -import { IntegrationSchema } from "@opencode-ai/core/integration/schema" +import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" @@ -12,6 +12,7 @@ import { DataMigrationTable } from "@opencode-ai/core/data-migration.sql" import { CredentialTable } from "@opencode-ai/core/credential/sql" import { tmpdir } from "../fixture/tmpdir" import { it } from "../lib/effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" function layer(dir: string) { const database = Database.layerFromPath(path.join(dir, "credential.db")).pipe(Layer.fresh) @@ -22,7 +23,7 @@ function layer(dir: string) { ) return Credential.layer.pipe( Layer.provide(database), - Layer.provide(EventV2.defaultLayer), + Layer.provide(AppNodeBuilder.build(EventV2.node)), Layer.provideMerge(importer), ) } @@ -86,7 +87,7 @@ describe("Credential auth-v2 migration", () => { const credentials = yield* Credential.Service return { all: yield* credentials.all(), - list: yield* credentials.list(IntegrationSchema.ID.make("kilo")), + list: yield* credentials.list(Integration.ID.make("kilo")), } }).pipe(Effect.provide(layer(tmp.path))) @@ -114,10 +115,10 @@ describe("Credential auth-v2 migration", () => { auth.pipe( Effect.flatMap(() => Effect.gen(function* () { - const integration = IntegrationSchema.ID.make("kilo") - const active = new Credential.OAuth({ + const integration = Integration.ID.make("kilo") + const active = Credential.OAuth.make({ type: "oauth", - methodID: IntegrationSchema.MethodID.make("oauth"), + methodID: Integration.MethodID.make("oauth"), refresh: "refresh-second", access: "access-second", expires: 2, diff --git a/packages/core/test/kilocode/database-migration-compat.test.ts b/packages/core/test/kilocode/database-migration-compat.test.ts index ebabb5e0ff7..8546df16b37 100644 --- a/packages/core/test/kilocode/database-migration-compat.test.ts +++ b/packages/core/test/kilocode/database-migration-compat.test.ts @@ -11,6 +11,8 @@ import path from "path" import { tmpdir } from "../fixture/tmpdir" import { SessionHistory } from "@opencode-ai/core/session/history" import { SessionV2 } from "@opencode-ai/core/session" +import { ensure } from "@opencode-ai/core/kilocode/database-compat" +import { Database } from "@opencode-ai/core/database/database" const make = EffectDrizzleSqlite.makeWithDefaults() const run = (effect: Effect.Effect) => @@ -43,18 +45,30 @@ describe("database migration compatibility", () => { ) yield* DatabaseMigration.applyOnly(db, migrations.slice(split)) + yield* ensure(db) // This is the projection shape written by the CLI bundled with VS Code v7.4.7. yield* db.run( sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('message', 'ses_session', 'user', 1, 1, '{}')`, ) yield* db.run(sql`UPDATE session_message SET data = '{"text":"updated"}' WHERE id = 'message'`) + yield* db.run( + sql`INSERT INTO session_context_epoch (session_id, baseline, agent, snapshot, baseline_seq, revision) VALUES ('ses_session', 'baseline', 'build', '{}', 0, 0)`, + ) + yield* db.run( + sql`UPDATE session_context_epoch SET replacement_seq = 4, revision = revision + 1 WHERE session_id = 'ses_session'`, + ) expect(yield* db.get(sql`SELECT id, seq, data FROM session_message WHERE id = 'message'`)).toEqual({ id: "message", seq: null, data: '{"text":"updated"}', }) + expect( + yield* db.get( + sql`SELECT agent, replacement_seq AS replacementSeq, revision FROM session_context_epoch WHERE session_id = 'ses_session'`, + ), + ).toEqual({ agent: "build", replacementSeq: 4, revision: 1 }) yield* db.run( sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_sequenced', 'ses_session', 'user', 1, 2, 2, '{"text":"current","files":[],"agents":[],"time":{"created":2}}')`, ) @@ -119,6 +133,37 @@ describe("database migration compatibility", () => { ) }) + test("keeps released context epoch writes compatible on fresh current databases", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "kilo.db") + await Effect.runPromise( + Database.Service.use((service) => + Effect.gen(function* () { + const db = service.db + yield* ensure(db) + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('project', '/repo', 1, 1, '[]')`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'project', 'session', '/repo', 'Session', '7.4.7', 1, 1)`, + ) + yield* db.run( + sql`INSERT INTO session_context_epoch (session_id, baseline, agent, snapshot, baseline_seq, revision) VALUES ('session', 'baseline', 'build', '{}', 0, 0)`, + ) + yield* db.run( + sql`UPDATE session_context_epoch SET replacement_seq = 4, revision = revision + 1 WHERE session_id = 'session'`, + ) + + expect( + yield* db.get( + sql`SELECT agent, replacement_seq AS replacementSeq, revision FROM session_context_epoch WHERE session_id = 'session'`, + ), + ).toEqual({ agent: "build", replacementSeq: 4, revision: 1 }) + }), + ).pipe(Effect.provide(Database.layerFromPath(filename)), Effect.scoped), + ) + }) + test("preserves sequenced projections when repairing an already-migrated database", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/kilocode/event-storage-compat.test.ts b/packages/core/test/kilocode/event-storage-compat.test.ts index 07a448a388a..27746ec9e5a 100644 --- a/packages/core/test/kilocode/event-storage-compat.test.ts +++ b/packages/core/test/kilocode/event-storage-compat.test.ts @@ -1,5 +1,6 @@ import { expect } from "bun:test" import { DateTime, Effect, Layer, Schema, Stream } from "effect" +import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -8,10 +9,23 @@ import { testEffect } from "../lib/effect" import { EventTable } from "@opencode-ai/core/event/sql" import { SessionMessage } from "@opencode-ai/core/session/message" import * as StoredMessage from "@opencode-ai/core/kilocode/session-message" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Prompt } from "@opencode-ai/core/session/prompt" +import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const it = testEffect(Layer.mergeAll(events, database)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([EventV2.node, Database.node]), [[Database.node, database]])) +const replay = testEffect( + AppNodeBuilder.build(LayerNode.group([EventV2.node, Database.node, SessionProjector.node]), [ + [Database.node, database], + ]), +) it.effect("decodes legacy durable tool content without exposing it to consumers", () => Effect.gen(function* () { @@ -34,10 +48,10 @@ it.effect("decodes legacy durable tool content without exposing it to consumers" }, }) - const stored = yield* events.aggregateEvents({ aggregateID: sessionID }).pipe(Stream.take(1), Stream.runHead) + const stored = yield* events.durable({ aggregateID: sessionID }).pipe(Stream.take(1), Stream.runHead) expect(stored._tag).toBe("Some") if (stored._tag === "None") return - expect(stored.value.event.data).toMatchObject({ + expect(stored.value.data).toMatchObject({ content: [ { type: "file", @@ -95,6 +109,76 @@ it.effect("writes released durable tool and compaction shapes", () => }), ) +replay.effect("reads and replays released prompt promotion events", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const sessionID = SessionV2.ID.make("ses_prompt_promoted_compat") + const messageID = SessionMessage.ID.make("msg_prompt_promoted_compat") + const prompt = Prompt.make({ text: "Promoted from a released session" }) + + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "compat", + directory: "/project", + title: "compat", + version: "test", + }) + .run() + .pipe(Effect.orDie) + + yield* events.replayAll( + [ + { + id: EventV2.ID.make("evt_prompt_admitted_compat"), + type: "session.next.prompt.admitted.1", + aggregateID: sessionID, + seq: 0, + data: { timestamp: 1, sessionID, messageID, prompt, delivery: "queue" }, + }, + { + id: EventV2.ID.make("evt_prompt_promoted_compat"), + type: "session.next.prompt.promoted.1", + aggregateID: sessionID, + seq: 1, + data: { timestamp: 2, sessionID, messageID, prompt, timeCreated: 1 }, + }, + ], + { publish: true }, + ) + + const history = yield* EventV2.readAggregate(db, { + aggregateID: sessionID, + limit: 10, + manifest: SessionDurable, + }) + expect(history.events.map((event) => event.type)).toEqual([ + "session.next.prompt.admitted", + "session.next.prompt.promoted", + ]) + expect(yield* events.durable({ aggregateID: sessionID }).pipe(Stream.take(2), Stream.runCollect)).toHaveLength(2) + expect(yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, messageID)).get()).toMatchObject({ + delivery: "queue", + promoted_seq: 1, + }) + expect( + yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, messageID)).get(), + ).toMatchObject({ + id: messageID, + type: "user", + seq: 1, + }) + }), +) + it.effect("stores self-contained compaction projections for released readers", () => Effect.sync(() => { const encoded = StoredMessage.encode({ diff --git a/packages/core/test/kilocode/exit-code.test.ts b/packages/core/test/kilocode/exit-code.test.ts index a316e839b38..e0e9be9f202 100644 --- a/packages/core/test/kilocode/exit-code.test.ts +++ b/packages/core/test/kilocode/exit-code.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { settle } from "@opencode-ai/core/kilocode/exit-code" import { testEffect } from "../lib/effect" -const fx = testEffect(CrossSpawnSpawner.defaultLayer) +const fx = testEffect(AppNodeBuilder.build(CrossSpawnSpawner.node)) const code = (exit: readonly [number | null, NodeJS.Signals | null]) => Effect.runPromise(settle(exit)) diff --git a/packages/core/test/kilocode/grep-tool.test.ts b/packages/core/test/kilocode/grep-tool.test.ts index 84731edb2a2..c341292a6d3 100644 --- a/packages/core/test/kilocode/grep-tool.test.ts +++ b/packages/core/test/kilocode/grep-tool.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" @@ -36,6 +38,7 @@ const references = (items: Reference.Info[] = []) => Reference.Service, Reference.Service.of({ transform: () => Effect.die("unused"), + reload: () => Effect.die("unused"), replace: () => Effect.die("unused"), list: () => Effect.succeed(items), }), @@ -51,19 +54,16 @@ describe("GrepTool managed output", () => { await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, "first\nneedle\nlast") - const base = Layer.mergeAll( - ApplicationTools.layer, - FSUtil.defaultLayer, - Global.layerWith({ data }), - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(worktree) }))), - permission(), - references(), - Ripgrep.defaultLayer, - ) - const store = ToolOutputStore.layer.pipe(Layer.provide(base)) - const registry = ToolRegistry.layer.pipe(Layer.provide(base), Layer.provide(store)) - const grep = GrepTool.layer.pipe(Layer.provide(base), Layer.provide(registry)) - const layer = Layer.mergeAll(base, store, registry, grep) + const layers = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, GrepTool.node]), [ + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [Global.node, Global.layerWith({ data })], + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(worktree) }))), + ], + [PermissionV2.node, permission()], + [Reference.node, references()], + ]) const result = await Effect.gen(function* () { const registry = yield* ToolRegistry.Service return yield* executeTool(registry, { @@ -71,7 +71,7 @@ describe("GrepTool managed output", () => { ...toolIdentity, call: { type: "tool-call", id: "call-grep-managed", name: "grep", input: { pattern: "needle", path: output } }, }) - }).pipe(Effect.provide(layer), Effect.scoped, Effect.runPromise) + }).pipe(Effect.provide(layers), Effect.scoped, Effect.runPromise) expect(result.type).toBe("text") if (result.type !== "text") return @@ -88,18 +88,16 @@ describe("GrepTool managed output", () => { await fs.mkdir(data) await fs.writeFile(output, "first\nneedle\nlast") - const base = Layer.mergeAll( - ApplicationTools.layer, - FSUtil.defaultLayer, - Global.layerWith({ data }), - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(worktree) }))), - permission(), - references(), - Ripgrep.defaultLayer, - ) - const store = ToolOutputStore.layer.pipe(Layer.provide(base)) - const registry = ToolRegistry.layer.pipe(Layer.provide(base), Layer.provide(store)) - const grep = GrepTool.layer.pipe(Layer.provide(base), Layer.provide(registry)) + const layers = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, GrepTool.node]), [ + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [Global.node, Global.layerWith({ data })], + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(worktree) }))), + ], + [PermissionV2.node, permission()], + [Reference.node, references()], + ]) const result = await Effect.gen(function* () { const tools = yield* ToolRegistry.Service return yield* executeTool(tools, { @@ -112,7 +110,7 @@ describe("GrepTool managed output", () => { input: { pattern: "needle", path: output }, }, }) - }).pipe(Effect.provide(Layer.mergeAll(base, store, registry, grep)), Effect.scoped, Effect.runPromise) + }).pipe(Effect.provide(layers), Effect.scoped, Effect.runPromise) expect(result.type).toBe("text") if (result.type !== "text") return @@ -132,20 +130,20 @@ describe("GrepTool managed output", () => { await fs.writeFile(path.join(docs, "guide.md"), "reference needle") await fs.writeFile(output, "retained needle") const requests: PermissionV2.AssertInput[] = [] - const source = new Reference.LocalSource({ type: "local", path: AbsolutePath.make(docs) }) - const base = Layer.mergeAll( - ApplicationTools.layer, - FSUtil.defaultLayer, - Global.layerWith({ data }), - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(worktree) }))), - permission(requests), - references([new Reference.Info({ name: "docs", path: source.path, source })]), - Ripgrep.defaultLayer, + const source = Reference.LocalSource.make({ type: "local", path: AbsolutePath.make(docs) }) + const layers = AppNodeBuilder.build( + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, GrepTool.node]), + [ + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [Global.node, Global.layerWith({ data })], + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(worktree) }))), + ], + [PermissionV2.node, permission(requests)], + [Reference.node, references([new Reference.Info({ name: "docs", path: source.path, source })])], + ], ) - const store = ToolOutputStore.layer.pipe(Layer.provide(base)) - const registry = ToolRegistry.layer.pipe(Layer.provide(base), Layer.provide(store)) - const grep = GrepTool.layer.pipe(Layer.provide(base), Layer.provide(registry)) - const layer = Layer.mergeAll(base, store, registry, grep) const run = (id: string, input: Record) => Effect.gen(function* () { const tools = yield* ToolRegistry.Service @@ -154,7 +152,7 @@ describe("GrepTool managed output", () => { ...toolIdentity, call: { type: "tool-call", id, name: "grep", input }, }) - }).pipe(Effect.provide(layer), Effect.scoped, Effect.runPromise) + }).pipe(Effect.provide(layers), Effect.scoped, Effect.runPromise) const result = await run("call-grep-reference", { pattern: "needle", reference: "docs" }) expect(result.type).toBe("text") diff --git a/packages/core/test/kilocode/integration-settlement.test.ts b/packages/core/test/kilocode/integration-settlement.test.ts index 9085d2526fb..cb9be21aab9 100644 --- a/packages/core/test/kilocode/integration-settlement.test.ts +++ b/packages/core/test/kilocode/integration-settlement.test.ts @@ -5,13 +5,14 @@ import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { it } from "../lib/effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // Regression coverage for Kilo's OAuth attempt settlement guards: persistence // happens before completion is exposed, and settlement is atomic with // cancellation, expiry, and timeouts. const layer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), + Layer.provide(AppNodeBuilder.build(EventV2.node)), Layer.provide( Layer.mock(Credential.Service)({ create: () => Effect.die("unexpected credential creation"), @@ -24,17 +25,17 @@ function connectionLayer( created: Array<{ integrationID: Integration.ID label?: string - value: Credential.Info + value: Credential.Value }>, ) { return Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), + Layer.provide(AppNodeBuilder.build(EventV2.node)), Layer.provide( Layer.mock(Credential.Service)({ create: (input) => Effect.sync(() => { created.push(input) - return new Credential.Stored({ + return new Credential.Info({ id: Credential.ID.create(), integrationID: input.integrationID, label: input.label ?? "default", @@ -50,7 +51,7 @@ function connectionLayer( describe("Integration settlement guards", () => { it.effect("fails auto OAuth when credential persistence fails", () => { const failed = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), + Layer.provide(AppNodeBuilder.build(EventV2.node)), Layer.provide( Layer.mock(Credential.Service)({ create: () => Effect.die(new Error("database unavailable")), @@ -62,7 +63,7 @@ describe("Integration settlement guards", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Browser" }, @@ -72,7 +73,7 @@ describe("Integration settlement guards", () => { url: "https://example.com/authorize", instructions: "Sign in", callback: Effect.succeed( - new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), + Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), ), }), }), @@ -89,7 +90,7 @@ describe("Integration settlement guards", () => { it.effect("fails code OAuth when credential persistence fails", () => { const failed = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), + Layer.provide(AppNodeBuilder.build(EventV2.node)), Layer.provide( Layer.mock(Credential.Service)({ create: () => Effect.die(new Error("database unavailable")), @@ -101,7 +102,7 @@ describe("Integration settlement guards", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -112,7 +113,7 @@ describe("Integration settlement guards", () => { instructions: "Paste the code", callback: () => Effect.succeed( - new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), + Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), ), }), }), @@ -135,16 +136,16 @@ describe("Integration settlement guards", () => { Effect.gen(function* () { const started = yield* Deferred.make() const release = yield* Deferred.make() - const created: Credential.Stored[] = [] + const created: Parameters[0][] = [] const delayed = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), + Layer.provide(AppNodeBuilder.build(EventV2.node)), Layer.provide( Layer.mock(Credential.Service)({ create: (input) => Effect.gen(function* () { yield* Deferred.succeed(started, undefined) yield* Deferred.await(release) - const credential = new Credential.Stored({ + const credential = new Credential.Info({ id: Credential.ID.create(), integrationID: input.integrationID, label: input.label ?? "default", @@ -162,7 +163,7 @@ describe("Integration settlement guards", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -173,7 +174,7 @@ describe("Integration settlement guards", () => { instructions: "Paste the code", callback: () => Effect.succeed( - new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), + Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), ), }), }), @@ -201,7 +202,7 @@ describe("Integration settlement guards", () => { const created: Array<{ integrationID: Integration.ID label?: string - value: Credential.Info + value: Credential.Value }> = [] return Effect.gen(function* () { const started = yield* Deferred.make() @@ -209,7 +210,7 @@ describe("Integration settlement guards", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -222,7 +223,7 @@ describe("Integration settlement guards", () => { Deferred.succeed(started, undefined).pipe( Effect.andThen(Deferred.await(release)), Effect.as( - new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), + Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), ), ), }), @@ -250,7 +251,7 @@ describe("Integration settlement guards", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -285,7 +286,7 @@ describe("Integration settlement guards", () => { const started = yield* Deferred.make() let closed = false const stalled = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), + Layer.provide(AppNodeBuilder.build(EventV2.node)), Layer.provide( Layer.mock(Credential.Service)({ create: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), @@ -298,7 +299,7 @@ describe("Integration settlement guards", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -310,7 +311,7 @@ describe("Integration settlement guards", () => { instructions: "Paste the code", callback: () => Effect.succeed( - new Credential.OAuth({ + Credential.OAuth.make({ type: "oauth", methodID, access: "access", diff --git a/packages/core/test/kilocode/linux-sandbox.test.ts b/packages/core/test/kilocode/linux-sandbox.test.ts index 3e3e5eed922..227ba797e1b 100644 --- a/packages/core/test/kilocode/linux-sandbox.test.ts +++ b/packages/core/test/kilocode/linux-sandbox.test.ts @@ -10,6 +10,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { backendSupport, run, type Profile } from "@kilocode/sandbox" import { CurrentProxyFactory, startProxy, type ProxyFactory } from "@kilocode/sandbox" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" const linux = process.platform === "linux" ? test : test.skip const linuxIPv6 = process.platform === "linux" && supportsIPv6() ? test : test.skip @@ -44,7 +45,7 @@ function execute(command: string, args: ReadonlyArray, cwd: string, poli .spawn(ChildProcess.make(command, args, { cwd })) .pipe(Effect.flatMap((handle) => handle.exitCode)), ), - ).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)), + ).pipe(Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), ) return factory ? effect.pipe(Effect.provideService(CurrentProxyFactory, factory)) : effect } @@ -68,7 +69,7 @@ function output(command: string, args: ReadonlyArray, cwd: string, polic ), ), ), - ).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer), Effect.provideService(CurrentProxyFactory, factory)), + ).pipe(Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), Effect.provideService(CurrentProxyFactory, factory)), ) } @@ -726,7 +727,7 @@ linux("applies the profile environment without inheriting denied values", async ) .pipe(Effect.flatMap((handle) => handle.exitCode)), ), - ).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)), + ).pipe(Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), ) expect(Number(await Effect.runPromise(effect))).toBe(0) } finally { @@ -807,7 +808,7 @@ linux("terminates daemonized descendants when the command scope closes", async ( }) }), ), - ).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)), + ).pipe(Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), ), ) diff --git a/packages/core/test/kilocode/provider-isolation.test.ts b/packages/core/test/kilocode/provider-isolation.test.ts index c2d2d498bc3..d41184cac4b 100644 --- a/packages/core/test/kilocode/provider-isolation.test.ts +++ b/packages/core/test/kilocode/provider-isolation.test.ts @@ -4,69 +4,73 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model, provider } from "../plugin/provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) + +// A custom provider that merely points at an official endpoint must not inherit that +// provider's attribution headers, and its models must stay enabled. +const providers: readonly { id: string; api: ProviderV2.Api }[] = [ + { + id: "custom-llmgateway", + api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, + }, + { + id: "custom-nvidia", + api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, + }, + { id: "custom-openrouter", api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" } }, + { id: "custom-vercel", api: { type: "aisdk", package: "@ai-sdk/vercel" } }, + { + id: "custom-zenmux", + api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, + }, +] + +const models = ["gpt-5-chat-latest", "openai/gpt-5-chat"] + +const addPlugins = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const integration = yield* Integration.Service + yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration)) + for (const item of [NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) { + yield* item.effect(host) + } +}) describe("provider attribution isolation", () => { it.effect("leaves custom providers with official endpoints untouched", () => Effect.gen(function* () { - const plugins = yield* PluginV2.Service const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - yield* plugins.add({ - ...LLMGatewayPlugin, - effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), - }) - for (const plugin of [NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) { - yield* plugins.add(plugin) - } - - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const items = [ - provider("custom-llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - }), - provider("custom-nvidia", { - api: { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://integrate.api.nvidia.com/v1", - }, - }), - provider("custom-openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - }), - provider("custom-vercel", { - api: { type: "aisdk", package: "@ai-sdk/vercel" }, - }), - provider("custom-zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - }), - ] - - for (const item of items) { - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request.headers.Existing = "value" + yield* catalog.transform((draft) => { + for (const item of providers) { + draft.provider.update(ProviderV2.ID.make(item.id), (entry) => { + entry.api = item.api + entry.request.headers.Existing = "value" }) } - for (const id of ["gpt-5-chat-latest", "openai/gpt-5-chat"]) { - const item = model("custom-openrouter", id) - catalog.model.update(item.providerID, item.id, () => {}) + for (const id of models) { + draft.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make(id), () => {}) } }) + yield* addPlugins() - for (const id of ["custom-llmgateway", "custom-nvidia", "custom-openrouter", "custom-vercel", "custom-zenmux"]) { - expect((yield* catalog.provider.get(ProviderV2.ID.make(id))).request.headers).toEqual({ Existing: "value" }) + for (const item of providers) { + expect((yield* catalog.provider.get(ProviderV2.ID.make(item.id)))?.request.headers).toEqual({ + Existing: "value", + }) } - for (const id of ["gpt-5-chat-latest", "openai/gpt-5-chat"]) { - expect((yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make(id))).enabled).toBe( + for (const id of models) { + expect((yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make(id)))?.enabled).toBe( true, ) } diff --git a/packages/core/test/kilocode/reference-materialization.test.ts b/packages/core/test/kilocode/reference-materialization.test.ts index bf57f93b708..51e5f048c32 100644 --- a/packages/core/test/kilocode/reference-materialization.test.ts +++ b/packages/core/test/kilocode/reference-materialization.test.ts @@ -3,13 +3,10 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Git } from "@opencode-ai/core/git" import { Global } from "@opencode-ai/core/global" import { Reference } from "@opencode-ai/core/reference" -import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { commit, git, gitRemote } from "../fixture/git" import { tmpdir } from "../fixture/tmpdir" import { it } from "../lib/effect" @@ -30,13 +27,10 @@ describe("Kilo reference compatibility", () => { repos: path.join(fixture.root, "repos"), state: path.join(fixture.root, "state"), }) - const deps = Layer.mergeAll(global, FSUtil.defaultLayer) - const cache = RepositoryCache.layer.pipe( - Layer.provide(EffectFlock.layer.pipe(Layer.provide(deps))), - Layer.provide(Git.defaultLayer), - Layer.provide(deps), - ) - const layer = Reference.layer.pipe(Layer.provide(cache), Layer.provide(events), Layer.provide(global)) + const layer = AppNodeBuilder.build(Reference.node, [ + [Global.node, global], + [EventV2.node, events], + ]) return Effect.gen(function* () { const previous = process.env.KILO_REPO_CLONE_GITHUB_BASE_URL @@ -50,16 +44,15 @@ describe("Kilo reference compatibility", () => { ) const references = yield* Reference.Service - const update = yield* references.transform() - const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" }) + const source = Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "main" }) const file = path.join(fixture.root, "repos", "github.com", "owner", "repo", "README.md") - yield* update((editor) => editor.add("docs", source)) + yield* references.transform((editor) => editor.add("docs", source)) yield* Effect.promise(() => content(file, "one\n")) expect(normalize(yield* Effect.promise(() => fs.readFile(file, "utf8")))).toBe("one\n") yield* Effect.promise(() => commit(fixture.source, "two\n", "update")) - yield* update((editor) => editor.add("docs", source)) + yield* references.transform((editor) => editor.add("docs", source)) yield* Effect.promise(() => content(file, "two\n")) expect(normalize(yield* Effect.promise(() => fs.readFile(file, "utf8")))).toBe("two\n") }).pipe(Effect.scoped, Effect.provide(layer)) diff --git a/packages/core/test/kilocode/search-target.test.ts b/packages/core/test/kilocode/search-target.test.ts index 48838b0db36..08d53e1073f 100644 --- a/packages/core/test/kilocode/search-target.test.ts +++ b/packages/core/test/kilocode/search-target.test.ts @@ -2,13 +2,14 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect, Exit, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/core/fs-util" import * as SearchTarget from "@opencode-ai/core/kilocode/search-target" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, Ripgrep.defaultLayer)) +const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, AppNodeBuilder.build(Ripgrep.node))) const withTmp = (f: (dir: string) => Effect.Effect) => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/kilocode/session-runner-model.test.ts b/packages/core/test/kilocode/session-runner-model.test.ts index 5f28d4609fa..1adf22ba7c3 100644 --- a/packages/core/test/kilocode/session-runner-model.test.ts +++ b/packages/core/test/kilocode/session-runner-model.test.ts @@ -10,7 +10,7 @@ import { it } from "../lib/effect" describe("SessionRunnerModel Kilo credentials", () => { it.effect("maps OAuth account IDs to Kilo organization routing", () => Effect.gen(function* () { - const model = new ModelV2.Info({ + const model = ModelV2.Info.make({ id: ModelV2.ID.make("test-model"), providerID: ProviderV2.ID.make("kilo"), name: "Test model", @@ -21,19 +21,19 @@ describe("SessionRunnerModel Kilo credentials", () => { url: "https://api.kilo.ai/openrouter", }, capabilities: { tools: true, input: ["text"], output: ["text"] }, - request: { headers: {}, body: {}, generation: {}, options: {} }, + request: { headers: {}, body: {} }, variants: [], - time: { released: DateTime.makeUnsafe(0) }, + time: { released: 0 }, cost: [], status: "active", enabled: true, limit: { context: 100, output: 20 }, }) - const credential = new Credential.Stored({ + const credential = Credential.Info.make({ id: Credential.ID.create(), integrationID: Integration.ID.make("kilo"), label: "Work", - value: new Credential.OAuth({ + value: Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("oauth"), refresh: "refresh", @@ -43,11 +43,7 @@ describe("SessionRunnerModel Kilo credentials", () => { }), }) - const resolved = yield* SessionRunnerModel.fromCatalogModel( - model, - { type: "credential", id: credential.id, label: credential.label }, - credential, - ) + const resolved = yield* SessionRunnerModel.fromCatalogModel(model, credential.value) expect(resolved.route.defaults.http?.body).toMatchObject({ kilocodeOrganizationId: "org-enterprise" }) expect(resolved.route.defaults.http?.body).not.toHaveProperty("accountID") diff --git a/packages/core/test/legacy-event-schema.test.ts b/packages/core/test/legacy-event-schema.test.ts new file mode 100644 index 00000000000..d9a2833b698 --- /dev/null +++ b/packages/core/test/legacy-event-schema.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { SessionV1 as Wire } from "@opencode-ai/schema/session-v1" +import { SessionV1 } from "../src/v1/session" + +describe("legacy event schema compatibility", () => { + test("Core references canonical SessionV1 definitions", () => { + expect(SessionV1.Event.Created).toBe(Wire.Event.Created) + expect(SessionV1.Event.PartUpdated).toBe(Wire.Event.PartUpdated) + }) + + test("Core retains NamedError constructor identity", () => { + const error = new SessionV1.APIError({ message: "failed", isRetryable: false }) + expect(error).toBeInstanceOf(SessionV1.APIError) + expect(error.toObject()).toEqual({ name: "APIError", data: { message: "failed", isRetryable: false } }) + }) +}) diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index bb022f85181..46137b8fd16 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -2,10 +2,9 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { Effect, Exit, Layer } from "effect" +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" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -13,15 +12,12 @@ import { it } from "./lib/effect" const provide = (directory: string) => Effect.provide( - FileSystem.layer.pipe( - Layer.provide( - Layer.mergeAll( - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - ), - ), - ), + LayerNode.compile(FileSystem.node, [ + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + ]), ) const withTmp = (f: (directory: string) => Effect.Effect) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 69dba2ae0a5..2815c320740 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,14 +1,22 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Equal, Hash, Layer, Schema } from "effect" -import { Tool } from "@opencode-ai/core/public" +import { DateTime, Effect, Equal, Hash, Schema } from "effect" +import { Tool } from "@opencode-ai/core/tool/tool" +import { define } from "@kilocode/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +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 { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { toolDefinitions } from "./lib/tool" @@ -24,37 +32,33 @@ 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), - LocationServiceMap.layer.pipe( - Layer.provide(applicationTools), - Layer.provide( - Layer.mergeAll( - Project.defaultLayer, - EventV2.defaultLayer, - Credential.defaultLayer, - Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").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", () => { - it.effect("compares equivalent location refs by value", () => - Effect.sync(() => { - const directory = AbsolutePath.make("/project") - expect(Equal.equals(Location.Ref.make({ directory }), Location.Ref.make({ directory }))).toBe(true) - expect(Hash.hash(Location.Ref.make({ directory }))).toBe( - Hash.hash(Location.Ref.make({ directory, workspaceID: undefined })), - ) - }), + it.live("reuses cached services for constructed and decoded location refs", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const directory = AbsolutePath.make(dir.path) + const constructed = Location.Ref.make({ directory }) + const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory }) + + expect(constructed).toEqual({ directory, workspaceID: undefined }) + expect(decoded).toEqual(constructed) + expect(Equal.equals(constructed, decoded)).toBe(true) + expect(Hash.hash(constructed)).toBe(Hash.hash(decoded)) + expect(yield* locations.contextEffect(constructed)).toBe(yield* locations.contextEffect(decoded)) + }), + ), + ), + ), ) it.live("isolates location state while sharing location policy with catalog", () => @@ -83,18 +87,18 @@ describe("LocationServiceMap", () => { const update = (directory: string) => Effect.gen(function* () { - yield* PluginBoot.Service.use((boot) => boot.wait()) yield* Reference.Service const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) return { providers: yield* catalog.provider.all(), tools: yield* toolDefinitions(yield* ToolRegistry.Service), } }).pipe( Effect.scoped, - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Effect.provide( + LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })), + ), ) const blockedState = yield* update(blocked.path) @@ -135,4 +139,88 @@ describe("LocationServiceMap", () => { ), ), ) + + it.live("rejects an unavailable selected model during location model resolution", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + yield* Effect.promise(() => + fs.writeFile( + path.join(dir.path, "opencode.json"), + JSON.stringify({ + providers: { + unavailable: { + name: "Unavailable", + api: { type: "native", settings: {} }, + models: { chat: { disabled: true } }, + }, + }, + }), + ), + ) + const failure = yield* SessionRunnerModel.Service.use((models) => + models.resolve( + SessionV2.Info.make({ + id: SessionV2.ID.make("ses_unavailable_model"), + projectID: ProjectV2.ID.global, + title: "test", + model: { + id: ModelV2.ID.make("chat"), + providerID: ProviderV2.ID.make("unavailable"), + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location, + }), + ), + ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.ModelUnavailableError", + providerID: "unavailable", + modelID: "chat", + }) + }), + ), + ), + ) + + it.live("installs public plugins into a location", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const reviewer = define({ + id: "reviewer", + effect: (ctx) => + ctx.agent + .transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + .pipe(Effect.asVoid), + }) + yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect) + + expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ + description: "Reviews code", + mode: "subagent", + }) + }).pipe( + Effect.scoped, + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))), + ), + ), + ), + ) }) diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index b9fcba0e354..b09be42daa6 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" import { Effect, Layer, Schema } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +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 { AbsolutePath } from "@opencode-ai/core/schema" @@ -12,14 +12,12 @@ import { it } from "./lib/effect" function provide(directory: string) { return Effect.provide( - LocationMutation.layer.pipe( - Layer.provide( - Layer.mergeAll( - FSUtil.defaultLayer, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - ), - ), - ), + LayerNode.compile(LocationMutation.node, [ + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + ]), ) } diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index 327c5bff9fa..77e9e96745b 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -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", () => diff --git a/packages/core/test/model-request.test.ts b/packages/core/test/model-request.test.ts deleted file mode 100644 index c8ac12d4708..00000000000 --- a/packages/core/test/model-request.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { ModelRequest } from "@opencode-ai/core/model-request" - -describe("ModelRequest", () => { - test("partitions AI SDK model and models.dev mode options", () => { - expect( - ModelRequest.normalizeAiSdkOptions("@ai-sdk/openai", { - maxOutputTokens: 4096, - temperature: 0.2, - reasoningEffort: "high", - serviceTier: "priority", - custom_extension: { enabled: true }, - }), - ).toEqual({ - generation: { maxTokens: 4096, temperature: 0.2 }, - options: { reasoningEffort: "high", serviceTier: "priority" }, - body: { custom_extension: { enabled: true } }, - }) - }) - - test("keeps unknown-provider options as compatibility fields", () => { - expect(ModelRequest.normalizeAiSdkOptions(undefined, { temperature: 0.2, reasoningEffort: "high" })).toEqual({ - generation: { temperature: 0.2 }, - options: {}, - body: { reasoningEffort: "high" }, - }) - }) - - test("does not consult inherited package-name properties", () => { - expect(ModelRequest.normalizeAiSdkOptions("__proto__", { reasoningEffort: "high" })).toEqual({ - generation: {}, - options: {}, - body: { reasoningEffort: "high" }, - }) - }) - - test("normalizes models.dev wire aliases owned by native protocols", () => { - expect(ModelRequest.normalizeAiSdkOptions("@ai-sdk/openai", { service_tier: "priority" })).toEqual({ - generation: {}, - options: { serviceTier: "priority" }, - body: {}, - }) - }) -}) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 8e6a9aa68e8..e7adc4c090f 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,11 +1,12 @@ import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" import { Effect, Layer, Ref } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { FSUtil } from "@opencode-ai/core/fs-util" +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 { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises" import path from "path" @@ -87,13 +88,13 @@ const makeMockClient = (state: Ref.Ref) => ) const buildLayer = (state: Ref.Ref) => - // 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(ModelsDev.layer).pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(EventV2.defaultLayer), + Layer.fresh( + AppNodeBuilder.build(ModelsDev.node, [ + [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], + ]), ) const writeCacheText = (text: string, mtimeMs?: number) => @@ -157,15 +158,12 @@ describe("ModelsDev Service", () => { Effect.gen(function* () { yield* writeCacheText("{") const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const context = yield* Layer.build(buildLayer(state)) const result = yield* Effect.acquireUseRelease( Effect.sync(() => { Flag.KILO_DISABLE_MODELS_FETCH = false }), - () => - provided( - state, - ModelsDev.Service.use((s) => s.get()), - ), + () => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)), () => Effect.sync(() => { Flag.KILO_DISABLE_MODELS_FETCH = true diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 5f7fbb16d3a..92beb1fa53a 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -3,52 +3,35 @@ 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 { 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 database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events)) -const project = Project.layer.pipe( - Layer.provide(database), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(directories), -) -const store = SessionStore.layer.pipe(Layer.provide(database)) -const sessions = SessionV2.layer.pipe( - Layer.provide(database), - Layer.provide(events), - Layer.provide(project), - Layer.provide(store), - Layer.provide(SessionExecution.noopLayer), -) -const layer = MoveSession.layer.pipe( - Layer.provide(database), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(events), - Layer.provide(project), - Layer.provide(sessions), -) const it = testEffect( - Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions), + AppNodeBuilder.build( + LayerNode.group([ + MoveSession.node, + Database.node, + EventV2.node, + ProjectDirectories.node, + Project.node, + SessionProjector.node, + SessionStore.node, + ]), + ), ) function abs(input: string) { diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index c149116cd5e..7e4a5763bf0 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -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) => ) 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", () => { @@ -60,7 +53,7 @@ describe("Npm.add", () => { return yield* npm.add(spec) }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) - expect(Option.isSome(entry.entrypoint)).toBe(true) + expect(entry.entrypoint).toBeDefined() }) }) diff --git a/packages/core/test/oauth-page.test.ts b/packages/core/test/oauth-page.test.ts new file mode 100644 index 00000000000..880a6892f0f --- /dev/null +++ b/packages/core/test/oauth-page.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test" +import { OauthCallbackPage } from "../src/oauth/page" + +describe("OauthCallbackPage", () => { + test("escapes bootstrap options embedded in the inline script", () => { + const html = OauthCallbackPage.bootstrap({ + provider: `xAI`, + tokenPath: `/token`, + }) + + expect(html.match(/<\/script>/g)).toHaveLength(1) + expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`) + expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`) + }) +}) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index ebe06400e98..a556f1fce21 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -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" @@ -12,37 +14,28 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" 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" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") const current = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) -const events = EventV2.layer.pipe(Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - 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 saved = PermissionSaved.layer.pipe(Layer.provide(database)) -const layer = PermissionV2.locationLayer.pipe( - Layer.provideMerge(database), - Layer.provideMerge(store), - Layer.provideMerge(events), - Layer.provideMerge(current), - Layer.provideMerge(sessions), - Layer.provideMerge(SessionExecution.noopLayer), - Layer.provideMerge(saved), -) -const it = testEffect(layer) function setup(rules: PermissionV2.Ruleset = []) { return Effect.gen(function* () { @@ -74,8 +67,7 @@ function setup(rules: PermissionV2.Ruleset = []) { function setRules(rules: PermissionV2.Ruleset) { return Effect.gen(function* () { const agents = yield* AgentV2.Service - const update = yield* agents.transform() - yield* update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("test"), (agent) => { agent.permissions = [...rules] }), @@ -130,7 +122,7 @@ describe("PermissionV2", () => { Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions.push({ action: "read", resource: "*", effect: "deny" }) }), @@ -139,7 +131,7 @@ describe("PermissionV2", () => { expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" }) expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "deny" }) - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions = [] }), @@ -187,8 +179,7 @@ describe("PermissionV2", () => { .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service - const update = yield* agents.transform() - yield* update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }] }), @@ -214,7 +205,7 @@ describe("PermissionV2", () => { .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service - yield* agents.update((editor) => { + yield* agents.transform((editor) => { editor.remove(AgentV2.ID.make("test")) editor.remove(AgentV2.ID.make("build")) }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index d89d531147c..2c822d65bdc 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,90 +1,71 @@ import { describe, expect } from "bun:test" -import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect, Exit, Fiber } from "effect" +import { define } from "@kilocode/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" -import { State } from "@opencode-ai/core/state" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" +import { PluginTestLayer } from "./plugin/fixture" -const events = Layer.mock(EventV2.Service)({ - publish: (definition, data) => - Effect.succeed({ - id: EventV2.ID.make("evt_plugin_test"), - type: definition.type, - data, - }), -}) -const plugins = PluginV2.layer.pipe(Layer.provide(events)) - -function state() { - return State.create({ - initial: () => ({ values: [] as string[] }), - editor: (draft) => ({ - add: (value: string) => draft.values.push(value), - }), - }) -} +const it = testEffect(PluginTestLayer) describe("PluginV2", () => { - it.effect("closes plugin-owned scopes when the registry layer finalizes", () => + it.effect("waits for a plugin and returns immediately once active", () => Effect.gen(function* () { - const values = state() - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) + const plugins = yield* PluginV2.Service + const id = PluginV2.ID.make("waited") + const waiting = yield* plugins.wait(id).pipe(Effect.forkChild) - yield* plugin.add({ - id: PluginV2.ID.make("scoped"), - effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("scoped")) - }), - }) - expect(values.get().values).toEqual(["scoped"]) - - yield* Scope.close(layerScope, Exit.void) - expect(values.get().values).toEqual([]) + yield* plugins.add(id, () => Effect.void) + yield* Fiber.join(waiting) + yield* plugins.wait(id) }), ) - it.effect("serializes same-ID additions and leaves one removable attachment", () => + it.effect("propagates plugin activation defects to waiters", () => Effect.gen(function* () { - const values = state() - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) - const id = PluginV2.ID.make("shared") - const firstStarted = yield* Deferred.make() - const releaseFirst = yield* Deferred.make() + const plugins = yield* PluginV2.Service + const id = PluginV2.ID.make("failed") + const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild) - const first = yield* plugin - .add({ - id, - effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("first")) - yield* Deferred.succeed(firstStarted, undefined) - yield* Deferred.await(releaseFirst) - }), + const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit) + const pending = yield* Fiber.join(waiting) + const later = yield* plugins.wait(id).pipe(Effect.exit) + + expect(Exit.isFailure(added)).toBe(true) + expect(Exit.isFailure(pending)).toBe(true) + expect(Exit.isFailure(later)).toBe(true) + }), + ) + + it.effect("adds, replaces, and removes plugins", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + let description = "first" + + const managed = () => + define({ + id: "managed", + effect: (ctx) => + ctx.agent + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = description + }), + ) + .pipe(Effect.asVoid), }) - .pipe(Effect.forkChild) - yield* Deferred.await(firstStarted) - const second = yield* plugin - .add({ - id, - effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("second")) - }), - }) - .pipe(Effect.forkChild({ startImmediately: true })) - expect(values.get().values).toEqual(["first"]) + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) - yield* Deferred.succeed(releaseFirst, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) - expect(values.get().values).toEqual(["second"]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") - yield* plugin.remove(id) - expect(values.get().values).toEqual([]) + description = "second" + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") + + yield* plugins.remove(PluginV2.ID.make("managed")) + expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) }) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index ddc4fc09d8d..47da0021b39 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -1,28 +1,31 @@ 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" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +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", () => Effect.gen(function* () { const command = yield* CommandV2.Service - yield* CommandPlugin.Plugin.effect.pipe( - Effect.provideService(CommandV2.Service, command), + yield* CommandPlugin.Plugin.effect( + host({ + command: { transform: command.transform, reload: command.reload }, + }), + ).pipe( Effect.provideService( Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project })), diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts new file mode 100644 index 00000000000..8b1d9add788 --- /dev/null +++ b/packages/core/test/plugin/fixture.ts @@ -0,0 +1,61 @@ +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 { 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 { Reference } from "@opencode-ai/core/reference" +import { SkillV2 } from "@opencode-ai/core/skill" +import { Global } from "@opencode-ai/core/global" +import { Effect, Layer } from "effect" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { tempLocationLayer } from "../fixture/location" + +// kilocode_change - Credential imports Global.data/auth.json on startup, so without this the suite +// reads the developer's real credential store and its results depend on whether they are logged in. +const globalLayer = Global.layerWith({ data: fs.mkdtempSync(path.join(os.tmpdir(), "kilo-plugin-test-")) }) + +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], + [Global.node, globalLayer], // kilocode_change + ], +) diff --git a/packages/core/test/plugin/fixtures/config-effect-plugin.ts b/packages/core/test/plugin/fixtures/config-effect-plugin.ts new file mode 100644 index 00000000000..ca391fed347 --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-effect-plugin.ts @@ -0,0 +1,15 @@ +import { define } from "@kilocode/plugin/v2/effect" +import { Effect } from "effect" + +export default define({ + id: "config-effect-plugin", + effect: (ctx) => + ctx.agent + .transform((agents) => { + agents.update("effect-configured", (agent) => { + agent.description = ctx.options.description + agent.mode = "subagent" + }) + }) + .pipe(Effect.asVoid), +}) diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts new file mode 100644 index 00000000000..0617c040501 --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@kilocode/plugin/v2/promise" + +export default define({ + id: "config-promise-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("configured", (agent) => { + agent.description = ctx.options.description + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/plugin/fixtures/invalid-plugin.ts b/packages/core/test/plugin/fixtures/invalid-plugin.ts new file mode 100644 index 00000000000..b1c6ea436a5 --- /dev/null +++ b/packages/core/test/plugin/fixtures/invalid-plugin.ts @@ -0,0 +1 @@ +export default {} diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts new file mode 100644 index 00000000000..63679da6b5a --- /dev/null +++ b/packages/core/test/plugin/host.ts @@ -0,0 +1,303 @@ +import type { PluginContext } from "@kilocode/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@kilocode/sdk/v2/types" +import { Effect } from "effect" + +type Overrides = Partial> + +export function host(overrides: Overrides = {}): PluginContext { + return { + options: {}, + agent: overrides.agent ?? { + transform: () => Effect.die("unused agent.transform"), + reload: () => Effect.die("unused agent.reload"), + }, + aisdk: overrides.aisdk ?? { + sdk: () => Effect.die("unused aisdk.sdk"), + language: () => Effect.die("unused aisdk.language"), + }, + catalog: overrides.catalog ?? { + transform: () => Effect.die("unused catalog.transform"), + reload: () => Effect.die("unused catalog.reload"), + }, + command: overrides.command ?? { + transform: () => Effect.die("unused command.transform"), + reload: () => Effect.die("unused command.reload"), + }, + integration: overrides.integration ?? { + transform: () => Effect.die("unused integration.transform"), + reload: () => Effect.die("unused integration.reload"), + connection: { + active: () => Effect.die("unused integration.connection.active"), + resolve: () => Effect.die("unused integration.connection.resolve"), + }, + }, + plugin: overrides.plugin ?? { + add: () => Effect.die("unused plugin.add"), + remove: () => Effect.die("unused plugin.remove"), + }, + reference: overrides.reference ?? { + transform: () => Effect.die("unused reference.transform"), + reload: () => Effect.die("unused reference.reload"), + }, + skill: overrides.skill ?? { + transform: () => Effect.die("unused skill.transform"), + reload: () => Effect.die("unused skill.reload"), + }, + } +} + +export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { + return { + reload: agent.reload, + transform: (callback) => + agent.transform((draft) => + callback({ + list: () => draft.list().map(agentInfo), + get: (id) => { + const value = draft.get(AgentV2.ID.make(id)) + return value && agentInfo(value) + }, + default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), + update: (id, update) => + draft.update(AgentV2.ID.make(id), (value) => { + const current = agentInfo(value) + update(current) + Object.assign(value, current, { id: AgentV2.ID.make(current.id) }) + }), + remove: (id) => draft.remove(AgentV2.ID.make(id)), + }), + ), + } +} + +export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] { + return { + reload: catalog.reload, + transform: (callback) => + catalog.transform((draft) => + callback({ + provider: { + list: () => + draft.provider.list().map((value) => ({ + provider: providerInfo(value.provider), + models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])), + })), + get: (id) => { + const value = draft.provider.get(ProviderV2.ID.make(id)) + return ( + value && { + provider: providerInfo(value.provider), + models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])), + } + ) + }, + update: (id, update) => + draft.provider.update(ProviderV2.ID.make(id), (value) => { + const current = providerInfo(value) + update(current) + Object.assign(value, current, { id: ProviderV2.ID.make(current.id) }) + }), + remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)), + }, + model: { + get: (providerID, modelID) => { + const value = draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)) + return value && modelInfo(value) + }, + update: (providerID, modelID, update) => + draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), (value) => { + const current = modelInfo(value) + update(current) + Object.assign(value, current, { + id: ModelV2.ID.make(current.id), + providerID: ProviderV2.ID.make(current.providerID), + family: current.family === undefined ? undefined : ModelV2.Family.make(current.family), + variants: current.variants.map((variant) => ({ + ...variant, + id: ModelV2.VariantID.make(variant.id), + })), + }) + }), + remove: (providerID, modelID) => + draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + default: { + get: () => { + const value = draft.model.default.get() + return value && { providerID: value.providerID, modelID: value.modelID } + }, + set: (providerID, modelID) => + draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + }, + }, + }), + ), + } +} + +export function integrationHost(integration: Integration.Interface): PluginContext["integration"] { + return { + reload: integration.reload, + connection: { + active: (id) => integration.connection.active(Integration.ID.make(id)), + resolve: (connection) => + integration.connection.resolve( + connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, + ), + }, + transform: (callback) => + integration.transform((draft) => + callback({ + list: () => draft.list().map((value) => ({ id: value.id, name: value.name })), + get: (id) => { + const value = draft.get(Integration.ID.make(id)) + return value && { id: value.id, name: value.name } + }, + update: (id, update) => draft.update(Integration.ID.make(id), update), + remove: (id) => draft.remove(Integration.ID.make(id)), + method: { + list: (id) => draft.method.list(Integration.ID.make(id)).map(method), + update: (input) => { + if ("authorize" in input) { + const methodID = Integration.MethodID.make(input.method.id) + const refresh = input.refresh + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: methodID }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: authorization.callback.pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + } + return { + ...authorization, + callback: (code: string) => + authorization.callback(code).pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + }), + ), + ...(refresh + ? { + refresh: (value: Credential.OAuth) => + refresh(value).pipe( + Effect.map((next) => + Credential.OAuth.make({ + ...next, + methodID: Integration.MethodID.make(next.methodID), + }), + ), + ), + } + : {}), + ...(input.label ? { label: input.label } : {}), + }) + return + } + if (input.method.type === "env") { + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, names: [...input.method.names] }, + }) + return + } + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: input.method, + }) + }, + remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)), + }, + }), + ), + } +} + +function method(value: Integration.Method) { + if (value.type === "env") return { type: value.type, names: [...value.names] } + if (value.type === "key") return { type: value.type, label: value.label } + return { + type: value.type, + id: value.id, + label: value.label, + prompts: value.prompts?.map((prompt) => { + if (prompt.type === "text") return { ...prompt } + return { ...prompt, options: prompt.options.map((option) => ({ ...option })) } + }), + } +} + +function internalMethod( + value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod, +): Integration.Method { + if (value.type === "env") return value + if (value.type === "key") return value + return { + ...value, + id: Integration.MethodID.make(value.id), + } +} + +function agentInfo(value: AgentV2.Info) { + return { + ...value, + model: value.model && { ...value.model }, + request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + permissions: value.permissions.map((permission) => ({ ...permission })), + } +} + +function providerInfo(value: ProviderV2.MutableInfo) { + return { + ...value, + api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, + request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + } +} + +function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) { + return { + ...value, + api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, + capabilities: { + ...value.capabilities, + input: [...value.capabilities.input], + output: [...value.capabilities.output], + }, + request: { + ...value.request, + headers: { ...value.request.headers }, + body: { ...value.request.body }, + }, + variants: value.variants.map((variant) => ({ + ...variant, + headers: { ...variant.headers }, + body: { ...variant.body }, + })), + time: { ...value.time }, + cost: value.cost.map((cost) => ({ ...cost, tier: cost.tier && { ...cost.tier }, cache: { ...cost.cache } })), + limit: { ...value.limit }, + } +} diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index f4a0e16bdd1..2890260aab8 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1,48 +1,138 @@ import path from "path" -import { describe, expect } from "bun:test" +import { afterAll, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" -import { Credential } from "@opencode-ai/core/credential" -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 { Flag } from "@opencode-ai/core/flag/flag" import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { PluginV2 } from "@opencode-ai/core/plugin" import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" -import { Policy } from "@opencode-ai/core/policy" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" +import { Global } from "@opencode-ai/core/global" +import fs from "node:fs" +import os from "node:os" import { testEffect } from "../lib/effect" +import { catalogHost, host, integrationHost } from "./host" -const events = EventV2.defaultLayer const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), ) -const plugins = PluginV2.layer.pipe(Layer.provide(events)) -const policy = Policy.layer.pipe(Layer.provide(locationLayer)) -const connections = Credential.layer.pipe( - Layer.fresh, - Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), - Layer.provide(events), -) -const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) -const catalog = Catalog.layer.pipe( - Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), -) -const layer = Layer.mergeAll( - catalog.pipe(Layer.provide(connections)), - integrations, - connections, - events, - locationLayer, - plugins, -) +// kilocode_change - Catalog pulls Credential, which imports Global.data/auth.json on startup, so +// without this the suite reads the developer's real credential store. +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-modelsdev-test-")) +const globalLayer = Global.layerWith({ data: dataDir }) +afterAll(() => fs.rmSync(dataDir, { recursive: true, force: true })) +const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, EventV2.node]), [ + [Location.node, locationLayer], + [Global.node, globalLayer], // kilocode_change +]) const it = testEffect(layer) describe("ModelsDevPlugin", () => { + it.effect("projects models.dev modes as separate models instead of variants", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + const models = ModelsDev.Service.of({ + get: () => + Effect.succeed({ + acme: { + id: "acme", + name: "Acme", + env: [], + npm: "@ai-sdk/openai-compatible", + api: "https://api.acme.test/v1", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + temperature: true, + tool_call: true, + cost: { + input: 2.5, + output: 15, + tiers: [ + { + tier: { type: "context", size: 272_000 }, + input: 3, + output: 18, + cache_read: 0.25, + }, + ], + context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 }, + }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + experimental: { + modes: { + fast: { + cost: { input: 5, output: 30, cache_read: 0.5 }, + provider: { + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, + }, + }, + }, + }, + }, + }, + }, + } satisfies Record), + refresh: () => Effect.void, + }) + + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + integration: integrationHost(integrations), + }), + ).pipe(Effect.provideService(ModelsDev.Service, models)) + + const providerID = ProviderV2.ID.make("acme") + const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4")) + const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast")) + + expect(base?.variants).toEqual([]) + expect(base?.request.body).toEqual({}) + expect(fast).toMatchObject({ + id: "gpt-5.4-fast", + providerID: "acme", + name: "GPT-5.4 Fast", + api: { id: "gpt-5.4" }, + request: { + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, + }, + variants: [], + }) + expect(fast?.cost).toEqual([ + { input: 5, output: 30, cache: { read: 0.5, write: 0 } }, + { + tier: { type: "context", size: 272_000 }, + input: 3, + output: 18, + cache: { read: 0.25, write: 0 }, + }, + { + tier: { type: "context", size: 200_000 }, + input: 5, + output: 22.5, + cache: { read: 0.5, write: 0 }, + }, + ]) + }), + ) + it.effect("registers key methods for providers with environment variables", () => Effect.acquireUseRelease( Effect.sync(() => { @@ -56,8 +146,14 @@ describe("ModelsDevPlugin", () => { }), () => Effect.gen(function* () { - yield* ModelsDevPlugin.effect const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + integration: integrationHost(integrations), + }), + ) expect(yield* integrations.list()).toEqual([ new Integration.Info({ id: Integration.ID.make("acme"), @@ -72,7 +168,7 @@ describe("ModelsDevPlugin", () => { connections: [], }), ]) - }).pipe(Effect.provide(ModelsDev.defaultLayer)), + }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))), (previous) => Effect.sync(() => { Flag.KILO_MODELS_PATH = previous.path diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts new file mode 100644 index 00000000000..3f193705009 --- /dev/null +++ b/packages/core/test/plugin/promise.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { PluginPromise } from "@opencode-ai/core/plugin/promise" +import { define } from "@kilocode/plugin/v2/promise" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +describe("fromPromise", () => { + it.effect("loads a promise plugin and registers a transform hook", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-example", + setup: async (ctx) => { + expect(ctx.options.mode).toBe("strict") + await ctx.agent.transform((draft) => { + draft.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect({ ...host, options: { mode: "strict" } }) + + expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({ + description: "Reviews code", + mode: "subagent", + }) + }), + ) + + it.effect("disposes a hook registration on request", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-dispose", + setup: async (ctx) => { + const registration = await ctx.agent.transform((draft) => { + draft.update("temp", (item) => { + item.description = "temporary" + }) + }) + await registration.dispose() + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect(host) + + expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index e2fbb8061a3..25d46bf83c6 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -1,21 +1,38 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { createAlibaba } from "@ai-sdk/alibaba" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" -import { it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AlibabaPlugin.effect(host) +}) describe("AlibabaPlugin", () => { it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "alibaba" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -23,12 +40,16 @@ describe("AlibabaPlugin", () => { it.effect("ignores non-Alibaba SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "alibaba" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -36,16 +57,16 @@ describe("AlibabaPlugin", () => { it.effect("matches the old bundled Alibaba SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-alibaba", "qwen"), - package: "@ai-sdk/alibaba", - options: { name: "custom-alibaba", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "custom-alibaba", apiKey: "test" }, + }) const expected = createAlibaba({ apiKey: "test", ...{ name: "custom-alibaba" } }).languageModel("qwen") const actual = result.sdk?.languageModel("qwen") expect(actual?.provider).toBe(expected.provider) @@ -56,9 +77,13 @@ describe("AlibabaPlugin", () => { it.effect("uses the old default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) - const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } }) - const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const item = ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" }, + }) + const result = yield* aisdk.runSDK({ model: item, package: "@ai-sdk/alibaba", options: {} }) const language = result.sdk?.languageModel(item.api.id) expect(language?.modelId).toBe("qwen-plus") expect(language?.provider).toBe("alibaba.chat") diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index e1ae5bd6793..5d24879e492 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -1,10 +1,63 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AmazonBedrockPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) @@ -28,12 +81,10 @@ function openAIUrl(language: unknown, path: string, modelId: string) { describe("AmazonBedrockPlugin", () => { it.effect("moves endpoint option to api URL", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AmazonBedrockPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const bedrock = provider("amazon-bedrock", { + yield* catalog.transform((catalog) => { + const bedrock = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock), api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, request: { headers: {}, @@ -45,7 +96,8 @@ describe("AmazonBedrockPlugin", () => { item.request = bedrock.request }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock) + yield* addPlugin() + const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)) expect(result.api).toEqual({ type: "aisdk", package: "@ai-sdk/amazon-bedrock", @@ -59,22 +111,22 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://base.example", - endpoint: "https://endpoint.example", - region: "us-east-1", - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + endpoint: "https://endpoint.example", + region: "us-east-1", }, - {}, - ) + }) expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example") }), ), @@ -84,21 +136,21 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://base.example", - region: "us-east-1", - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + region: "us-east-1", }, - {}, - ) + }) expect(bedrockBaseURL(result.sdk)).toBe("https://base.example") }), ), @@ -118,16 +170,20 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(result.sdk).toBeDefined() expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") }), @@ -138,16 +194,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock", region: "eu-west-1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock", region: "eu-west-1" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") }), ), @@ -157,16 +213,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") }), ), @@ -176,16 +232,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") }), ), @@ -195,24 +251,24 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "option-token", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token") expect(headers).toEqual(["Bearer option-token"]) @@ -224,24 +280,24 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "option-token", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token") expect(headers).toEqual(["Bearer env-token"]) @@ -253,23 +309,25 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, - }), - package: "@ai-sdk/amazon-bedrock/mantle", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", - region: "us-east-2", + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", }, + }), + package: "@ai-sdk/amazon-bedrock/mantle", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + region: "us-east-2", }, - {}, - ) + }) const language = result.sdk.responses("openai.gpt-5.5") expect(openAIUrl(language, "/responses", "openai.gpt-5.5")).toBe( "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses", @@ -281,30 +339,33 @@ describe("AmazonBedrockPlugin", () => { it.effect("selects Mantle APIs without Bedrock cross-region prefixes", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, - }), - sdk: fakeSelectorSdk(calls), - options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, - }), - sdk: fakeSelectorSdk(calls), - options: { region: "us-east-1" }, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, + }), + sdk: fakeSelectorSdk(calls), + options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), + api: { + id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, + }), + sdk: fakeSelectorSdk(calls), + options: { region: "us-east-1" }, + }) expect(calls).toEqual(["responses:openai.gpt-5.5", "chat:openai.gpt-oss-safeguard-120b"]) }), ) @@ -312,18 +373,20 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores other Bedrock provider subpaths", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" }, - }), - package: "@ai-sdk/amazon-bedrock/anthropic", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/anthropic", + }, + }), + package: "@ai-sdk/amazon-bedrock/anthropic", + options: { name: "amazon-bedrock" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -340,23 +403,27 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", { body: "{}", @@ -371,53 +438,53 @@ describe("AmazonBedrockPlugin", () => { it.effect("applies legacy cross-region inference prefixes", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "ap-northeast-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "ap-southeast-2" }, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-northeast-1" }, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-southeast-2" }, + }) expect(calls).toEqual([ "languageModel:us.anthropic.claude-sonnet-4-5", "languageModel:eu.anthropic.claude-sonnet-4-5", @@ -432,17 +499,17 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"]) }), ), @@ -451,6 +518,7 @@ describe("AmazonBedrockPlugin", () => { it.effect("applies the full legacy cross-region prefix matrix", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const cases = [ { region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" }, @@ -518,17 +586,16 @@ describe("AmazonBedrockPlugin", () => { expected: "au.anthropic.claude-sonnet-4-5", }, ] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin() for (const item of cases) { - yield* plugin.trigger( - "aisdk.language", - { - model: model("amazon-bedrock", item.modelID), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: item.region }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), + api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: item.region }, + }) } expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`)) }), @@ -537,17 +604,17 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores non-Bedrock providers for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("openai", "anthropic.claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 85881c3e844..a4ad4d7950c 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -1,20 +1,36 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AnthropicPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("AnthropicPlugin", () => { it.effect("applies legacy beta headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("anthropic", { + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.anthropic), api: { type: "aisdk", package: "@ai-sdk/anthropic" }, request: { headers: { Existing: "1" }, body: {} }, }) @@ -23,75 +39,56 @@ describe("AnthropicPlugin", () => { draft.request = item.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", ) - expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1") + expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1") }), ) it.effect("ignores non-Anthropic providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) - expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"]).toBeUndefined() + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) + yield* addPlugin() + expect( + required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"], + ).toBeUndefined() }), ) it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* plugin.add(AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, }), + package: "@ai-sdk/anthropic", + options: { name: "custom-anthropic", apiKey: "test" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-anthropic", "claude-sonnet-4-5"), - package: "@ai-sdk/anthropic", - options: { name: "custom-anthropic", apiKey: "test" }, - }, - {}, - ) - expect(providers).toEqual(["custom-anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic") }), ) it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* plugin.add(AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, }), + package: "@ai-sdk/anthropic", + options: { name: "anthropic", apiKey: "test" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("anthropic", "claude-sonnet-4-5"), - package: "@ai-sdk/anthropic", - options: { name: "anthropic", apiKey: "test" }, - }, - {}, - ) - expect(providers).toEqual(["anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic") }), ) }) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 3101052cf9a..6b3f6c6a382 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -1,25 +1,76 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AzureCognitiveServicesPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("AzureCognitiveServicesPlugin", () => { it.effect("maps the resource env var to the Azure SDK baseURL", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzureCognitiveServicesPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")) + yield* addPlugin() + const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) expect(result.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -34,15 +85,16 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("leaves baseURL unset without resource env and ignores other providers", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzureCognitiveServicesPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const azure = provider("azure-cognitive-services", { + yield* catalog.transform((catalog) => { + const azure = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")), api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) - const openai = provider("openai") + const openai = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) catalog.provider.update(azure.id, (item) => { item.api = azure.api }) @@ -50,8 +102,9 @@ describe("AzureCognitiveServicesPlugin", () => { item.api = openai.api }) }) - const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")) - const openai = yield* catalog.provider.get(ProviderV2.ID.openai) + yield* addPlugin() + const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) + const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai)) expect(azure.request.body.baseURL).toBeUndefined() expect(azure.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" }) expect(openai.request.body.baseURL).toBeUndefined() @@ -63,17 +116,17 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("selects chat only for completion URLs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzureCognitiveServicesPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure-cognitive-services", "deployment"), - sdk: fakeSelectorSdk(calls), - options: { useCompletionUrls: true }, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -81,18 +134,25 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("uses the legacy Azure selector order and provider guard", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzureCognitiveServicesPlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - const ignored = yield* plugin.trigger( - "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + const ignored = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) expect(ignored.language).toBeUndefined() }), @@ -101,36 +161,34 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("falls back from responses to messages, chat, then languageModel", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(AzureCognitiveServicesPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure-cognitive-services", "messages-deployment"), - sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure-cognitive-services", "chat-deployment"), - sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure-cognitive-services", "language-deployment"), - sdk: { languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), + api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: sdk.languageModel }, + options: {}, + }) expect(calls).toEqual([ "messages:messages-deployment", "chat:chat-deployment", diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 91ab854d5cb..1d8172854cf 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,25 +1,76 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AzurePlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("AzurePlugin", () => { it.effect("resolves resourceName from env", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.api = { type: "aisdk", package: "@ai-sdk/azure" } }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), ) @@ -27,12 +78,10 @@ describe("AzurePlugin", () => { it.effect("keeps explicit resourceName over env and ignores other providers", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const azure = provider("azure", { + yield* catalog.transform((catalog) => { + const azure = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "from-config" } }, }) @@ -42,23 +91,20 @@ describe("AzurePlugin", () => { }) catalog.provider.update(ProviderV2.ID.openai, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") - expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() }), ), ) - // kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up - it.effect("falls back to env when configured resourceName is blank", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const azure = provider("azure", { + yield* catalog.transform((catalog) => { + const azure = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "" } }, }) @@ -67,7 +113,8 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), ) @@ -75,12 +122,10 @@ describe("AzurePlugin", () => { it.effect("falls back to env when configured resourceName is whitespace", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const azure = provider("azure", { + yield* catalog.transform((catalog) => { + const azure = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: " " } }, }) @@ -89,7 +134,8 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), ) @@ -98,16 +144,16 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AzurePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("azure", "deployment"), - package: "@ai-sdk/azure", - options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -116,14 +162,17 @@ describe("AzurePlugin", () => { it.effect("rejects missing resourceName when baseURL is not configured", () => withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* plugin.add(AzurePlugin) - const exit = yield* plugin - .trigger( - "aisdk.sdk", - { model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const exit = yield* aisdk + .runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure" }, + }) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), @@ -133,13 +182,17 @@ describe("AzurePlugin", () => { it.effect("selects chat only for completion URLs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -147,13 +200,17 @@ describe("AzurePlugin", () => { it.effect("selects chat from per-call useCompletionUrls", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -161,19 +218,18 @@ describe("AzurePlugin", () => { it.effect("ignores model useCompletionUrls when per-call option is unset", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure", "deployment", { - request: { headers: {}, body: { useCompletionUrls: true } }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + request: { headers: {}, body: { useCompletionUrls: true } }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) }), ) @@ -181,18 +237,25 @@ describe("AzurePlugin", () => { it.effect("uses the legacy Azure selector order and provider guard", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - const ignored = yield* plugin.trigger( - "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + const ignored = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) expect(ignored.language).toBeUndefined() }), @@ -201,26 +264,29 @@ describe("AzurePlugin", () => { it.effect("falls back through the legacy Azure selector order", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const make = (method: string) => (id: string) => { calls.push(`${method}:${id}`) return { modelId: id, provider: method, specificationVersion: "v3" } } - yield* plugin.add(AzurePlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("azure", "messages-deployment"), - sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: make("languageModel") }, + options: {}, + }) expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) }), ) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index aa192274d61..adcc1779b0d 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -1,12 +1,24 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cerebrasOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CerebrasPlugin.effect(host) +}) void mock.module("@ai-sdk/cerebras", () => ({ createCerebras: (options: Record) => { @@ -21,17 +33,15 @@ void mock.module("@ai-sdk/cerebras", () => ({ describe("CerebrasPlugin", () => { it.effect("applies the legacy integration header", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CerebrasPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } item.request.headers.Existing = "1" }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode", }) @@ -40,12 +50,10 @@ describe("CerebrasPlugin", () => { it.effect("ignores non-Cerebras providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CerebrasPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) - expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({}) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({}) }), ) @@ -53,16 +61,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), - package: "@ai-sdk/cerebras", - options: { name: "custom-cerebras", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/cerebras", + options: { name: "custom-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([{ name: "custom-cerebras", apiKey: "test" }]) expect(result.sdk.languageModel("llama-4-scout-17b-16e-instruct").provider).toBe("custom-cerebras") }), @@ -72,16 +87,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), - package: "@ai-sdk/cerebras", - options: { name: "configured-cerebras", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/cerebras", + options: { name: "configured-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([{ name: "configured-cerebras", apiKey: "test" }]) }), ) @@ -90,16 +112,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), - package: "@ai-sdk/groq", - options: { name: "custom-cerebras", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/groq", + options: { name: "custom-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([]) expect(result.sdk).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 72ad5da33f1..008b9a46a3e 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,8 +1,43 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" -import { it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CloudflareAIGatewayPlugin.effect(host) +}) + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} const aiGatewayCalls: Record[] = [] const unifiedCalls: string[] = [] @@ -78,16 +113,16 @@ describe("CloudflareAIGatewayPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined() }), ), @@ -98,24 +133,24 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - metadata: { invoked_by: "test", project: "opencode" }, - cacheTtl: 300, - cacheKey: "cache-key", - skipCache: true, - collectLog: false, - }, + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + metadata: { invoked_by: "test", project: "opencode" }, + cacheTtl: 300, + cacheKey: "cache-key", + skipCache: true, + collectLog: false, }, - {}, - ) + }) expect(aiGatewayCalls).toHaveLength(1) expect(aiGatewayCalls[0]).toEqual({ @@ -142,22 +177,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - headers: { - "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), - }, + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + headers: { + "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), }, }, - {}, - ) + }) expect(aiGatewayCalls[0]?.options).toMatchObject({ metadata: { invoked_by: "header", project: "opencode" }, @@ -171,22 +206,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gateway: "auth-gateway", - apiKey: "auth-token", - }, + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gateway: "auth-gateway", + apiKey: "auth-token", }, - {}, - ) + }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "env-account", @@ -208,22 +243,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gatewayId: "auth-gateway", - apiKey: "auth-token", - }, + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gatewayId: "auth-gateway", + apiKey: "auth-token", }, - {}, - ) + }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "auth-account", @@ -239,17 +274,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" }) }), @@ -261,17 +296,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -284,17 +319,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -313,17 +348,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -336,17 +371,24 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({ modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" }, @@ -364,17 +406,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 39323f77e1a..d1eeaa581ef 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,11 +1,63 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CloudflareWorkersAIPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( @@ -31,24 +83,23 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider" } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, - }, - {}, - ) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) + const sdk = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, + }) expect(provider.api).toEqual({ type: "aisdk", package: "test-provider", @@ -62,16 +113,14 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", url: "https://proxy.example/v1", @@ -84,39 +133,38 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, + }) expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions") }), ), ) - // kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up - it.effect("uses env account ID over configured account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider" } provider.request.body.accountId = "configured-acct" }), ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1", @@ -129,23 +177,26 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - apiKey: "auth-key", - baseURL: "https://proxy.example/v1", - headers: { custom: "header" }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + apiKey: "auth-key", + baseURL: "https://proxy.example/v1", + headers: { custom: "header" }, }, - {}, - ) + }) const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk))) expect(headers.authorization).toBe("Bearer env-key") expect(headers.custom).toBe("header") @@ -158,25 +209,24 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", }, - {}, - ) + }) expect(cloudflareURL(result.sdk)).toBe( "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions", ) @@ -187,17 +237,17 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("selects languageModel with the API model ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(result.language).toBeDefined() expect(calls).toEqual(["languageModel:@cf/api-model"]) }), @@ -207,18 +257,21 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/anthropic", - options: { name: "cloudflare-workers-ai" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/anthropic", + url: "https://proxy.example/v1", + }, + }), + package: "@ai-sdk/anthropic", + options: { name: "cloudflare-workers-ai" }, + }) expect(result.sdk).toBeUndefined() }), ), diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index a646c3eb6ce..c001ad935f1 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -1,11 +1,37 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cohereOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CoherePlugin.effect(host) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/cohere", () => ({ createCohere: (options: Record) => { @@ -24,20 +50,27 @@ describe("CoherePlugin", () => { it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CoherePlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cohere" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "cohere" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -45,16 +78,16 @@ describe("CoherePlugin", () => { it.effect("uses the model provider ID as the bundled SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CoherePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-cohere", "command-r-plus"), - package: "@ai-sdk/cohere", - options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, + }) expect(cohereOptions.at(-1)).toEqual({ name: "custom-cohere", @@ -68,14 +101,18 @@ describe("CoherePlugin", () => { it.effect("leaves language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(CoherePlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }) expect(result.language).toBeUndefined() expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index 43db117a908..9cb9a4866c2 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -1,20 +1,27 @@ -import { describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itAISDK = testEffect( - Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), -) -const deepinfraOptions: Record[] = [] +const it = testEffect(PluginTestLayer) +const deepinfraOptions: Record[] = [] const deepinfraLanguageModels: string[] = [] +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* DeepInfraPlugin.effect(host) +}) + void mock.module("@ai-sdk/deepinfra", () => ({ - createDeepInfra: (options: Record) => { + createDeepInfra: (options: Record) => { const captured = { ...options } deepinfraOptions.push(captured) return { @@ -36,12 +43,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -50,16 +61,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-deepinfra", "model"), - package: "@ai-sdk/deepinfra", - options: { name: "custom-deepinfra", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "custom-deepinfra", apiKey: "test" }, + }) expect(result.sdk.languageModel("model").provider).toBe("custom-deepinfra.chat") expect(deepinfraOptions).toEqual([{ name: "custom-deepinfra", apiKey: "test" }]) }), @@ -69,16 +80,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("deepinfra", "model"), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra", apiKey: "test" }, + }) expect(result.sdk.languageModel("model").provider).toBe("deepinfra.chat") expect(deepinfraOptions).toEqual([{ name: "deepinfra", apiKey: "test" }]) }), @@ -88,7 +99,8 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() const packages = [ "unmatched-package", "@ai-sdk/deepinfra-compatible", @@ -96,35 +108,50 @@ describe("DeepInfraPlugin", () => { ] yield* Effect.forEach(packages, (item) => Effect.gen(function* () { - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: item, + options: { name: "deepinfra" }, + }) expect(ignored.sdk).toBeUndefined() }), ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) expect(result.sdk).toBeDefined() expect(deepinfraOptions).toEqual([{ name: "deepinfra" }]) }), ) - itAISDK.effect("uses the default languageModel selection for DeepInfra models", () => + it.effect("uses the default languageModel selection for DeepInfra models", () => Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(DeepInfraPlugin) - const language = yield* aisdk.language( - model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { - api: { type: "aisdk", package: "@ai-sdk/deepinfra" }, + yield* addPlugin() + const sdkEvent = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct")), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + type: "aisdk", + package: "@ai-sdk/deepinfra", + }, }), - ) + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) + const result = yield* aisdk.runLanguage({ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.provider).toBe("deepinfra.chat") expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) }), diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 2b0be314ba9..9eda0351438 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -1,37 +1,38 @@ import { Npm } from "@opencode-ai/core/npm" import { describe, expect } from "bun:test" -import { Cause, Effect, Layer, Option } from "effect" +import { Cause, Effect, Layer } from "effect" import fs from "fs/promises" import os from "os" import path from "path" import { fileURLToPath } from "url" import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +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" import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { fixtureProvider, it, model, npmLayer } from "./provider-helper" +import { PluginTestLayer } from "./fixture" +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const fixtureProviderPath = fileURLToPath(fixtureProvider) -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) +const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node))) -function npmEntrypointLayer(entrypoint: Option.Option) { - return Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint }), - install: () => Effect.void, - which: () => Effect.succeed(Option.none()), - }), - ) +function npmEntrypoint(entrypoint?: string) { + return Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }) } -function dynamicPlugin(layer = npmLayer) { - return { id: DynamicProviderPlugin.id, effect: DynamicProviderPlugin.effect.pipe(Effect.provide(layer)) } -} +const addPlugin = Effect.fn(function* (npm?: Npm.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service))) +}) function tempEntrypoint(source: string) { return Effect.acquireRelease( @@ -48,17 +49,16 @@ function tempEntrypoint(source: string) { describe("DynamicProviderPlugin", () => { it.effect("creates an SDK from a provider factory export", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom", "test-model"), - package: fixtureProvider, - options: { name: "custom", marker: "dynamic" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + }) expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" }) expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } }) }), @@ -66,63 +66,65 @@ describe("DynamicProviderPlugin", () => { it.effect("does not override an SDK already supplied by an earlier plugin", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const sdk = { marker: "existing" } - yield* plugin.add(dynamicPlugin()) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom", "test-model"), - package: fixtureProvider, - options: { name: "custom", marker: "dynamic" }, - }, - { sdk }, - ) + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + sdk, + }) expect(result.sdk).toBe(sdk) }), ) it.effect("injects the provider ID as the SDK factory name", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-provider", "test-model"), - package: fixtureProvider, - options: { name: "custom-provider", marker: "dynamic" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom-provider", marker: "dynamic" }, + }) expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" }) }), ) it.effect("loads npm packages through their resolved import entrypoint", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(fixtureProviderPath)))) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("npm-provider", "test-model"), - package: "fixture-provider", - options: { name: "npm-provider", marker: "npm" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(npmEntrypoint(fixtureProviderPath)) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, + }), + package: "fixture-provider", + options: { name: "npm-provider", marker: "npm" }, + }) expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } }) }), ) itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.none()))) + yield* addPlugin(npmEntrypoint()) const exit = yield* aisdk - .language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -131,12 +133,14 @@ describe("DynamicProviderPlugin", () => { itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const exit = yield* aisdk .language( - model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }), + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" }, + }), ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") @@ -149,9 +153,14 @@ describe("DynamicProviderPlugin", () => { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(tmp.entrypoint)))) + yield* addPlugin(npmEntrypoint(tmp.entrypoint)) const exit = yield* aisdk - .language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -162,9 +171,10 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const language = yield* aisdk.language( - model("custom", "alias", { + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider }, }), ) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 8ee69b7dd49..c6a0427cdb7 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -1,11 +1,24 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" -import { it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const gatewayCalls: Record[] = [] const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GatewayPlugin.effect(host) +}) mock.module("@ai-sdk/gateway", () => ({ createGateway(options: Record) { @@ -27,12 +40,16 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "gateway" }, + }) expect(result.sdk).toBeDefined() expect(gatewayCalls).toHaveLength(1) }), @@ -42,17 +59,21 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("vercel", "anthropic/claude-sonnet-4"), - package: "@ai-sdk/gateway", - options: { name: "vercel", apiKey: "test-key" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel", apiKey: "test-key" }, + }) expect(gatewayCalls).toEqual([{ name: "vercel", apiKey: "test-key" }]) expect(result.sdk.languageModel("anthropic/claude-sonnet-4").provider).toBe("vercel") @@ -63,21 +84,28 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() for (const modelID of vercelGatewayModels) { - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/vercel", + options: { name: "vercel" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel" }, + }) expect(result.sdk).toBeDefined() } diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index f16b177e698..beef89ae8c0 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,35 +1,65 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GithubCopilotPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("GithubCopilotPlugin", () => { it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GithubCopilotPlugin) - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("github-copilot", "gpt-5"), - package: "@ai-sdk/openai-compatible", - options: { name: "github-copilot" }, - }, - {}, - ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("github-copilot", "gpt-5"), - package: "@ai-sdk/github-copilot", - options: { name: "github-copilot" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "github-copilot" }, + }) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/github-copilot", + options: { name: "github-copilot" }, + }) expect(ignored.sdk).toBeUndefined() expect(result.sdk).toBeDefined() }), @@ -38,17 +68,17 @@ describe("GithubCopilotPlugin", () => { it.effect("selects languageModel when responses and chat are absent", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "claude-sonnet-4"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4"]) }), ) @@ -56,17 +86,17 @@ describe("GithubCopilotPlugin", () => { it.effect("selects languageModel with the API model ID when responses and chat are absent", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4"]) }), ) @@ -74,33 +104,49 @@ describe("GithubCopilotPlugin", () => { it.effect("uses responses for gpt-5 models except gpt-5-mini", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), + api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), + api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), + api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([ "responses:gpt-5", "responses:gpt-5.1-codex", @@ -114,67 +160,63 @@ describe("GithubCopilotPlugin", () => { it.effect("uses the API model ID when selecting responses or chat", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:gpt-5", "chat:gpt-5-mini", "chat:claude-sonnet-4"]) }), ) it.effect("disables gpt-5-chat-latest before Copilot language selection", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GithubCopilotPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( - (yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) + .enabled, ).toBe(false) }), ) it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GithubCopilotPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( - (yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) + .enabled, ).toBe(true) }), ) @@ -182,13 +224,17 @@ describe("GithubCopilotPlugin", () => { it.effect("ignores non-Copilot providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index 3e036ad7125..f9880844d3d 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,10 +1,45 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" -import { it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const gitlabSDKOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GitLabPlugin.effect(host) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} void mock.module("gitlab-ai-provider", () => ({ VERSION: "test-version", @@ -30,12 +65,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }) expect(gitlabSDKOptions).toHaveLength(1) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://gitlab.com") expect(gitlabSDKOptions[0].apiKey).toBe("env-token") @@ -63,12 +102,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") }), ), @@ -84,28 +127,28 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: { - name: "gitlab", - instanceUrl: "https://configured.gitlab.example", - apiKey: "configured-token", - aiGatewayHeaders: { - "anthropic-beta": "configured-beta", - "x-gitlab-test": "1", - }, - featureFlags: { - duo_agent_platform: false, - custom_flag: true, - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { + name: "gitlab", + instanceUrl: "https://configured.gitlab.example", + apiKey: "configured-token", + aiGatewayHeaders: { + "anthropic-beta": "configured-beta", + "x-gitlab-test": "1", + }, + featureFlags: { + duo_agent_platform: false, + custom_flag: true, }, }, - {}, - ) + }) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://configured.gitlab.example") expect(gitlabSDKOptions[0].apiKey).toBe("configured-token") expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({ @@ -125,44 +168,45 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "gitlab" }, + }) expect(result.sdk).toBeUndefined() expect(gitlabSDKOptions).toHaveLength(0) }), ) - // kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up - it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("gitlab", "duo-workflow-custom", { - request: { - headers: {}, - body: { workflowRef: "ref", workflowDefinition: "definition" }, - }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, + request: { + headers: {}, + body: { workflowRef: "ref", workflowDefinition: "definition" }, }, - options: { featureFlags: { configured: true } }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([ ["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: "definition" }], ]) @@ -177,23 +221,23 @@ describe("GitLabPlugin", () => { it.effect("uses exact static workflow model ids when the provider recognizes them", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("gitlab", "duo-workflow-exact"), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), + api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } }, - options: { featureFlags: { configured: true } }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([ ["duo-workflow-exact", { featureFlags: { configured: true }, workflowDefinition: undefined }], ]) @@ -204,28 +248,27 @@ describe("GitLabPlugin", () => { it.effect("uses provider feature flags instead of request feature flags", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("gitlab", "duo-workflow-custom", { - request: { - headers: {}, - body: { featureFlags: { request_flag: true } }, - }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, + request: { + headers: {}, + body: { featureFlags: { request_flag: true } }, }, - options: { featureFlags: { configured: true } }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: undefined }]]) }), ) @@ -233,31 +276,30 @@ describe("GitLabPlugin", () => { it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("gitlab", "claude", { - request: { headers: { h: "v" }, body: {} }, - }), - sdk: { - workflowChat: () => undefined, - agenticChat: (id: string, options: unknown) => { - const selected = options as { - aiGatewayHeaders?: Record - featureFlags?: Record - } - calls.push([ - id, - { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, - ]) - }, + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + request: { headers: { h: "v" }, body: {} }, + }), + sdk: { + workflowChat: () => undefined, + agenticChat: (id: string, options: unknown) => { + const selected = options as { + aiGatewayHeaders?: Record + featureFlags?: Record + } + calls.push([ + id, + { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, + ]) }, - options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, }, - {}, - ) + options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, + }) expect(calls).toEqual([ ["claude", { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }], ]) diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index bdb6029487c..d94321f3bd6 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -1,10 +1,52 @@ +import { AISDK } from "@opencode-ai/core/aisdk" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* definition.effect(host) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function selector(calls: string[]) { + return (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + } +} describe("GoogleVertexAnthropicPlugin", () => { it.effect("resolves legacy project and location env on provider update", () => @@ -19,18 +61,19 @@ describe("GoogleVertexAnthropicPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")) - expect(provider.request.body.project).toBe("cloud-project") - expect(provider.request.body.location).toBe("cloud-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project, + ).toBe("cloud-project") + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("cloud-location") }), ), ) @@ -38,20 +81,21 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured project and location over env fallback", () => withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.request.body.project = "configured-project" provider.request.body.location = "configured-location" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")) - expect(provider.request.body.project).toBe("configured-project") - expect(provider.request.body.location).toBe("configured-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe( + "configured-project", + ) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("configured-location") }), ), ) @@ -69,16 +113,19 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://aiplatform.googleapis.com/v1/projects/gcp-project/locations/global/publishers/anthropic/models", ) @@ -92,16 +139,19 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://cloud-location-aiplatform.googleapis.com/v1/projects/project/locations/cloud-location/publishers/anthropic/models", ) @@ -112,16 +162,16 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "claude-sonnet-4-5"), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "eu" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models", ) @@ -131,16 +181,16 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured baseURL for google-vertex Anthropic models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "claude-sonnet-4-5"), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe("https://proxy.example/v1") }), ) @@ -148,26 +198,25 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("selects google-vertex Anthropic language models through V2 plugins", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.add(GoogleVertexAnthropicPlugin) - const sdkResult = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", " claude-sonnet-4-5 "), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "us" }, - }, - {}, - ) - const languageResult = yield* plugin.trigger( - "aisdk.language", - { - model: model("google-vertex", " claude-sonnet-4-5 "), - sdk: sdkResult.sdk, - options: {}, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin(GoogleVertexPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) + const sdkResult = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "us" }, + }) + const languageResult = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: sdkResult.sdk, + options: {}, + }) const language = languageResult.language as unknown as { config: { baseURL: string }; modelId: string } expect(language.config.baseURL).toBe( "https://aiplatform.us.rep.googleapis.com/v1/projects/project/locations/us/publishers/anthropic/models", @@ -179,17 +228,17 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexAnthropicPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("google-vertex-anthropic", " claude-sonnet-4-5 "), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin(GoogleVertexAnthropicPlugin) + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4-5"]) }), ) @@ -197,17 +246,17 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("ignores non Vertex Anthropic providers for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("google-vertex", "claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin(GoogleVertexAnthropicPlugin) + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index cb23cc452cd..388528bd65c 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,13 +1,65 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const vertexOptions: Record[] = [] const googleAuthOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GoogleVertexPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/google-vertex", () => ({ createVertex: (options: Record) => { @@ -37,11 +89,8 @@ void mock.module("google-auth-library", () => ({ describe("GoogleVertexPlugin", () => { it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.opencode, (provider) => { provider.api = { type: "aisdk", @@ -50,8 +99,9 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() - const provider = yield* catalog.provider.get(ProviderV2.ID.opencode) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode)) expect(provider.request.body).toEqual({}) }), ) @@ -68,11 +118,8 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -81,7 +128,8 @@ describe("GoogleVertexPlugin", () => { } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("google-cloud-project") expect(provider.request.body.location).toBe("google-vertex-location") expect(provider.api).toEqual({ @@ -108,10 +156,9 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -120,18 +167,20 @@ describe("GoogleVertexPlugin", () => { } }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google-vertex" }, - }, - {}, - ) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }) expect(provider.request.body.project).toBe("vertex-project") expect(provider.api).toEqual({ @@ -157,11 +206,8 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -172,7 +218,8 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "global" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("global") expect(provider.api).toEqual({ @@ -186,11 +233,8 @@ describe("GoogleVertexPlugin", () => { it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -201,7 +245,8 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "eu" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible", @@ -222,17 +267,15 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.request.body.project = "config-project" }), ) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")) + yield* addPlugin() + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("us-central1") }), @@ -249,18 +292,20 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google-vertex" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }) expect(vertexOptions).toHaveLength(1) expect(vertexOptions[0].project).toBe("env-project") expect(vertexOptions[0].location).toBe("env-location") @@ -274,21 +319,18 @@ describe("GoogleVertexPlugin", () => { googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("capture-openai-compatible"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.promise(async () => { - if (evt.model.providerID !== "google-vertex") return - if (evt.package !== "@ai-sdk/openai-compatible") return - expect(typeof evt.options.fetch).toBe("function") - await evt.options.fetch("https://vertex.example", { - headers: { "x-test": "1" }, - }) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + yield* aisdk.hook.sdk((evt) => + Effect.promise(async () => { + if (evt.model.providerID !== "google-vertex") return + if (evt.package !== "@ai-sdk/openai-compatible") return + expect(typeof evt.options.fetch).toBe("function") + await evt.options.fetch("https://vertex.example", { + headers: { "x-test": "1" }, + }) }), - }) + ) const originalFetch = fetch ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async ( input: Parameters[0], @@ -300,17 +342,18 @@ describe("GoogleVertexPlugin", () => { yield* Effect.acquireUseRelease( Effect.void, () => - plugin.trigger( - "aisdk.sdk", - { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "google-vertex" }, - }, - {}, - ), + aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "google-vertex" }, + }), () => Effect.sync(() => { ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch @@ -327,17 +370,17 @@ describe("GoogleVertexPlugin", () => { it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: model("google-vertex", " gemini-2.5-pro "), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* addPlugin() + yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), + api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:gemini-2.5-pro"]) }), ) diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index 9880ff3ae58..8c10689ca11 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -1,31 +1,37 @@ -import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { describe, expect } from "bun:test" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GooglePlugin.effect(host) +}) describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GooglePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-google", "gemini"), - package: "@ai-sdk/google", - options: { name: "custom-google", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }) expect(result.sdk).toBeDefined() expect(result.sdk?.languageModel("gemini").provider).toBe("custom-google") }), @@ -34,34 +40,39 @@ describe("GooglePlugin", () => { it.effect("ignores non-Google SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GooglePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google" }, + }) expect(result.sdk).toBeUndefined() }), ) - itWithAISDK.effect("uses default languageModel loading with provider ID parity", () => + it.effect("uses default languageModel loading with provider ID parity", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(GooglePlugin) - const language = yield* aisdk.language( - model("custom-google", "alias", { - api: { - id: ModelV2.ID.make("gemini-api"), - type: "aisdk", - package: "@ai-sdk/google", - }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, + yield* addPlugin() + const sdkEvent = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, }), - ) + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }) + const result = yield* aisdk.runLanguage({ + model: sdkEvent.model, + sdk: sdkEvent.sdk, + options: sdkEvent.options, + }) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.modelId).toBe("gemini-api") expect(language.provider).toBe("custom-google") }), diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index c6db66b1cb6..ac96ea98a3f 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -1,28 +1,38 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { createGroq } from "@ai-sdk/groq" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" -import { it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const aisdkIt = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GroqPlugin.effect(host) +}) describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "groq" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -30,12 +40,16 @@ describe("GroqPlugin", () => { it.effect("ignores non-Groq SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "groq" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -43,12 +57,16 @@ describe("GroqPlugin", () => { it.effect("only matches the bundled @ai-sdk/groq package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq/compat", + options: { name: "groq" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -56,16 +74,16 @@ describe("GroqPlugin", () => { it.effect("matches the old bundled Groq SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-groq", "llama"), - package: "@ai-sdk/groq", - options: { name: "custom-groq", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "custom-groq", apiKey: "test" }, + }) const expected = createGroq({ name: "custom-groq", apiKey: "test" } as Parameters[0] & { name: string }).languageModel("llama") @@ -75,26 +93,29 @@ describe("GroqPlugin", () => { }), ) - aisdkIt.effect("uses the default languageModel(api.id) behavior", () => + it.effect("uses the default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(GroqPlugin) - const result = yield* aisdk.language( - model("groq", "alias", { + yield* addPlugin() + const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters[0] & { + name: string + }) + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("llama-api"), type: "aisdk", package: "@ai-sdk/groq", }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, }), - ) - expect(result.modelId).toBe("llama-api") - expect(result.provider).toBe("groq.chat") + sdk, + options: { name: "groq", apiKey: "test" }, + }) + const language = result.language ?? sdk.languageModel(result.model.api.id) + expect(language.modelId).toBe("llama-api") + expect(language.provider).toBe("groq.chat") }), ) }) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts deleted file mode 100644 index c15928435bc..00000000000 --- a/packages/core/test/plugin/provider-helper.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Npm } from "@opencode-ai/core/npm" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { expect } from "bun:test" -import { Effect, Layer, Option } from "effect" -import { Catalog } from "@opencode-ai/core/catalog" -import { Integration } from "@opencode-ai/core/integration" -import { Credential } from "@opencode-ai/core/credential" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" - -export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) - -export const npmLayer = Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint: Option.none() }), - install: () => Effect.void, - which: () => Effect.succeed(Option.none()), - }), -) - -export const catalogLayer = Layer.succeed( - Catalog.Service, - Catalog.Service.of({ - transform: () => Effect.die("unexpected catalog.transform"), - provider: { - get: () => Effect.die("unexpected provider.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - }, - model: { - get: () => Effect.die("unexpected model.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - default: () => Effect.succeed(Option.none()), - small: () => Effect.succeed(Option.none()), - }, - }), -) - -const integrations = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), -) - -export const it = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(integrations), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - }), - ), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge(npmLayer), - ), -) - -type ProviderInput = Partial> & { - api?: ProviderV2.Api - request?: ProviderV2.Request -} - -type ModelInput = Partial> & { - api?: (ProviderV2.Api & { id?: ModelV2.ID }) | { id: ModelV2.ID } - request?: ModelV2.Info["request"] -} - -export function provider(providerID: string, options?: ProviderInput) { - return new ProviderV2.Info({ - ...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)), - api: options?.api ?? { - type: "aisdk", - package: "test-provider", - }, - ...options, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function model(providerID: string, modelID: string, options?: ModelInput) { - return new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), - ...options, - api: - options?.api && "type" in options.api - ? { id: ModelV2.ID.make(modelID), ...options.api } - : { - id: ModelV2.ID.make(modelID), - ...options?.api, - type: "aisdk", - package: "test-provider", - }, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function withEnv(vars: Record, fx: () => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) - for (const [key, value] of Object.entries(vars)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - return previous - }), - () => fx(), - (previous) => - Effect.sync(() => { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - }), - ) -} - -export function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -export function expectPluginRegistered(ids: string[], id: string) { - expect(ids).toContain(PluginV2.ID.make(id)) -} diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 4bc43c156f9..2db9a46674d 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -1,100 +1,124 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { AISDK } from "@opencode-ai/core/aisdk" // kilocode_change import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" // kilocode_change import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, model, provider, withEnv } from "./provider-helper" // kilocode_change +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* KiloPlugin.effect(host) +}) + +// kilocode_change start +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + for (const [key, value] of Object.entries(vars)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + return previous + }), + effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) +} +// kilocode_change end describe("KiloPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "kilo", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))), ) it.effect("applies legacy referer headers only to kilo", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - request: { headers: { Existing: "value" }, body: {} }, + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - draft.request = kilo.request - }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", // kilocode_change }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("uses the exact legacy Kilo header casing and set", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) }) + yield* addPlugin() - const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) - expect(result.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.kilo))?.request.headers).toEqual({ "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", // kilocode_change }) - expect(result.request.headers).not.toHaveProperty("http-referer") - expect(result.request.headers).not.toHaveProperty("x-title") - expect(result.request.headers).not.toHaveProperty("X-Source") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty( + "http-referer", + ) + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source") }), ) it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - }) - const custom = provider("custom-kilo", { - api: { type: "aisdk", package: "kilo" }, - }) - catalog.provider.update(custom.id, (draft) => { - draft.api = custom.api + catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => { + provider.api = { type: "aisdk", package: "kilo" } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.kilo))?.request.headers).toEqual({ "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", // kilocode_change }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({}) }), ) @@ -102,38 +126,40 @@ describe("KiloPlugin", () => { it.effect("routes the Kilo catalog through the Kilo Gateway SDK", () => withEnv({ KILO_API_KEY: undefined, KILO_ORG_ID: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - request: { headers: {}, body: { apiKey: "stored-token" } }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.kilo, (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } + provider.request = { headers: {}, body: { apiKey: "stored-token" } } }) }) - const updated = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) + yield* addPlugin() + const updated = yield* catalog.provider.get(ProviderV2.ID.kilo) - expect(updated.api).toEqual({ + expect(updated?.api).toEqual({ type: "aisdk", package: "@kilocode/kilo-gateway", url: "https://api.kilo.ai/api/openrouter", }) - expect(updated.request.body.kilocodeToken).toBe("stored-token") + expect(updated?.request.body.kilocodeToken).toBe("stored-token") - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("kilo", "kilo-auto/free"), - package: "@kilocode/kilo-gateway", - options: updated.request.body, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.kilo, ModelV2.ID.make("kilo-auto/free")), + api: { + id: ModelV2.ID.make("kilo-auto/free"), + type: "aisdk", + package: "@kilocode/kilo-gateway", + }, + }), + package: "@kilocode/kilo-gateway", + options: updated?.request.body ?? {}, + }) expect(result.sdk).toBeDefined() expect(typeof result.sdk.languageModel).toBe("function") expect(typeof result.sdk.anthropic).toBe("function") @@ -144,26 +170,21 @@ describe("KiloPlugin", () => { it.effect("keeps authenticated credentials ahead of inherited environment keys", () => withEnv({ KILO_API_KEY: "environment-token", KILO_ORG_ID: "environment-org" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("kilo", { - request: { + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.kilo, (provider) => { + provider.request = { headers: {}, body: { apiKey: "authenticated-token", kilocodeOrganizationId: "authenticated-org" }, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + } }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) + yield* addPlugin() + const result = yield* catalog.provider.get(ProviderV2.ID.kilo) - expect(result.request.body.apiKey).toBe("authenticated-token") - expect(result.request.body.kilocodeToken).toBe("authenticated-token") - expect(result.request.body.kilocodeOrganizationId).toBe("environment-org") + expect(result?.request.body.apiKey).toBe("authenticated-token") + expect(result?.request.body.kilocodeToken).toBe("authenticated-token") + expect(result?.request.body.kilocodeOrganizationId).toBe("environment-org") }), ), ) @@ -171,18 +192,14 @@ describe("KiloPlugin", () => { it.effect("keeps anonymous Kilo models available without credentials", () => withEnv({ KILO_API_KEY: undefined, KILO_ORG_ID: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("kilo"), () => {})) - const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.kilo, () => {})) + yield* addPlugin() + const result = yield* catalog.provider.get(ProviderV2.ID.kilo) - expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain( - ProviderV2.ID.make("kilo"), - ) - expect(result.request.body.apiKey).toBe("anonymous") - expect(result.request.body.kilocodeToken).toBe("anonymous") + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(ProviderV2.ID.kilo) + expect(result?.request.body.apiKey).toBe("anonymous") + expect(result?.request.body.kilocodeToken).toBe("anonymous") }), ), ) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index f92d5fc0bbb..f2b4fe4b4c3 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -3,78 +3,78 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const integration = yield* Integration.Service + yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration)) +}) describe("LLMGatewayPlugin", () => { - const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) { - const integrations = yield* Integration.Service - yield* plugin.add({ - ...LLMGatewayPlugin, - effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), - }) - }) - it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "llmgateway", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))), ) it.effect("applies legacy referer headers only to enabled llmgateway", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin) const integrations = yield* Integration.Service - yield* integrations.update((editor) => { + yield* integrations.transform((editor) => { editor.update(Integration.ID.make("llmgateway"), () => {}) editor.update(Integration.ID.make("openrouter"), () => {}) }) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const llmgateway = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(llmgateway.id, (draft) => { - draft.api = llmgateway.api - draft.request = llmgateway.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", // kilocode_change "X-Source": "kilo", // kilocode_change }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("does not apply legacy headers to a disabled llmgateway provider", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + const integrations = yield* Integration.Service + yield* integrations.transform((editor) => { + editor.update(Integration.ID.make("llmgateway"), () => {}) + }) + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.disabled = true + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined() - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index b442d4f4d6c..b1b1bc0638c 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -1,20 +1,38 @@ +import { AISDK } from "@opencode-ai/core/aisdk" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* MistralPlugin.effect(host) +}) describe("MistralPlugin", () => { it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(MistralPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -22,16 +40,16 @@ describe("MistralPlugin", () => { it.effect("ignores non-Mistral SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(MistralPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("mistral", "mistral-large"), - package: "@ai-sdk/openai-compatible", - options: { name: "mistral" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "mistral" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -39,22 +57,22 @@ describe("MistralPlugin", () => { it.effect("matches the old bundled Mistral SDK provider name for the bundled provider ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const providers: string[] = [] - yield* plugin.add(MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* aisdk.hook.sdk((event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, - {}, ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }) expect(result.sdk).toBeDefined() expect(providers).toEqual(["mistral.chat"]) }), @@ -63,26 +81,22 @@ describe("MistralPlugin", () => { it.effect("matches the old bundled Mistral SDK provider name for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const providers: string[] = [] - yield* plugin.add(MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* aisdk.hook.sdk((event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-mistral", "mistral-large"), - package: "@ai-sdk/mistral", - options: { name: "custom-mistral" }, - }, - {}, ) + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "custom-mistral" }, + }) expect(providers).toEqual(["mistral.chat"]) }), ) @@ -90,14 +104,23 @@ describe("MistralPlugin", () => { it.effect("leaves Mistral language selection on the default sdk.languageModel(api.id) path", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - const sdk = fakeSelectorSdk(calls) - yield* plugin.add(MistralPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} }, - {}, - ) + const sdk = { + languageModel: (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + }, + } + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }) const language = result.language ?? sdk.languageModel(result.model.api.id) expect(calls).toEqual(["languageModel:mistral-large"]) expect(language).toBeDefined() diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 36930b3cf6f..14fb2f75440 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -2,66 +2,66 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* NvidiaPlugin.effect(host) +}) describe("NvidiaPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "nvidia", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))), ) it.effect("applies NVIDIA tracking headers only to nvidia", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const nvidia = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: { Existing: "value" }, body: {} }, + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(nvidia.id, (draft) => { - draft.api = nvidia.api - draft.request = nvidia.request - }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", "X-BILLING-INVOKE-ORIGIN": "KiloCode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("adds billing origin for custom NVIDIA endpoints", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: {}, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", "X-BILLING-INVOKE-ORIGIN": "KiloCode", @@ -71,25 +71,23 @@ describe("NvidiaPlugin", () => { it.effect("preserves an explicit NVIDIA billing origin header", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, body: { baseURL: "https://integrate.api.nvidia.com/v1" }, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin", diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index e8bf1f7575f..4e74a37125d 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -1,28 +1,45 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" -import { it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* OpenAICompatiblePlugin.effect(host) +}) describe("OpenAICompatiblePlugin", () => { it.effect("preserves explicit includeUsage false and defaults it to true", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAICompatiblePlugin) - const defaulted = yield* plugin.trigger( - "aisdk.sdk", - { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } }, - {}, - ) - const disabled = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom", "model"), - package: "@ai-sdk/openai-compatible", - options: { name: "custom", includeUsage: false }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const defaulted = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom" }, + }) + const disabled = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom", includeUsage: false }, + }) expect(defaulted.options.includeUsage).toBe(true) expect(disabled.options.includeUsage).toBe(false) }), @@ -31,16 +48,16 @@ describe("OpenAICompatiblePlugin", () => { it.effect("defaults includeUsage for OpenAI-compatible package matches", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAICompatiblePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom", "model"), - package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", - options: { name: "custom" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", + options: { name: "custom" }, + }) expect(result.options.includeUsage).toBe(true) }), ) @@ -48,53 +65,42 @@ describe("OpenAICompatiblePlugin", () => { it.effect("uses the provider ID as the OpenAI-compatible provider name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const observed: string[] = [] - yield* plugin.add(OpenAICompatiblePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), + yield* addPlugin() + yield* aisdk.hook.sdk((event) => + Effect.sync(() => { + observed.push(event.sdk.languageModel("model").provider) }), - }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-provider", "model"), - package: "@ai-sdk/openai-compatible", - options: { name: "custom-provider", baseURL: "https://example.com/v1" }, - }, - {}, ) + yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom-provider", baseURL: "https://example.com/v1" }, + }) expect(observed).toEqual(["custom-provider.chat"]) }), ) it.effect("does not overwrite an SDK created by an earlier provider-specific plugin", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const sentinel = { languageModel: (modelID: string) => ({ modelID }) } - yield* plugin.add({ - id: PluginV2.ID.make("sentinel"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - evt.sdk = sentinel - }), + yield* aisdk.hook.sdk((event) => { + event.sdk = sentinel + }) + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai" }, }) - yield* plugin.add(OpenAICompatiblePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("cloudflare-workers-ai", "model"), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai" }, - }, - {}, - ) expect(result.sdk).toBe(sentinel) }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index d30b585b961..31a80f93190 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,25 +1,49 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -function add(plugin: PluginV2.Interface, integrations: Integration.Interface) { - return plugin.add({ - ...OpenAIPlugin, - effect: OpenAIPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), - }) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + const integrations = yield* Integration.Service + yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } } describe("OpenAIPlugin", () => { it.effect("registers browser and headless ChatGPT OAuth methods", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ { id: Integration.MethodID.make("chatgpt-browser"), @@ -38,16 +62,16 @@ describe("OpenAIPlugin", () => { it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-openai", "gpt-5"), - package: "@ai-sdk/openai", - options: { name: "custom-openai", apiKey: "test" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "custom-openai", apiKey: "test" }, + }) expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses") }), ) @@ -55,12 +79,16 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openai" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -68,19 +96,17 @@ describe("OpenAIPlugin", () => { it.effect("uses the Responses API for language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("openai", "alias", { - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:gpt-5"]) expect(result.language).toBeDefined() }), @@ -89,13 +115,17 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), @@ -103,36 +133,43 @@ describe("OpenAIPlugin", () => { it.effect("disables gpt-5-chat-latest during catalog transforms", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin, yield* Integration.Service) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } }) + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai" }, + }) catalog.provider.update(item.id, (draft) => { draft.api = item.api }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false) + yield* addPlugin() + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) + expect( + required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + ).toBe(false) }), ) it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin, yield* Integration.Service) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("custom-openai") - catalog.provider.update(item.id, () => {}) + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")), + api: { type: "aisdk", package: "test-provider" }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( - (yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled, + required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))) + .enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 01cabf35811..f27a8a1a7c0 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,47 +1,100 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Option } from "effect" +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 { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { it, model, provider, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) +const it = testEffect(PluginTestLayer) -const pluginWithIntegrations = (integrations: Integration.Interface) => ({ - ...OpencodePlugin, - effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), +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(EventV2.Service, events), + Effect.provideService(Integration.Service, integration), + ) }) +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] + describe("OpencodePlugin", () => { + // kilocode_change - Kilo does not register the OpenCode Console device/OAuth flow and does not let + // console.opencode.ai drive the catalog, so upstream's "registers account and service account + // methods" and "loads providers and models from the connected OpenCode server" tests are + // intentionally absent. See the note in src/plugin/provider/opencode.ts. + it.effect("uses a public key and disables paid models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) }), ), ) @@ -49,20 +102,25 @@ describe("OpencodePlugin", () => { it.effect("keeps free models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const free = model("opencode", "free", { cost: cost(0) }) - catalog.model.update(item.id, free.id, (draft) => { - draft.cost = [...free.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")), + api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" }, + cost: cost(0), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) }), ), ) @@ -70,20 +128,27 @@ describe("OpencodePlugin", () => { it.effect("treats output-only cost as free without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) - catalog.model.update(item.id, outputOnly.id, (draft) => { - draft.cost = [...outputOnly.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")), + api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" }, + cost: cost(0, 1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe( + true, + ) }), ), ) @@ -91,20 +156,25 @@ describe("OpencodePlugin", () => { it.effect("uses OPENCODE_API_KEY as credentials", () => withEnv({ OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -112,27 +182,32 @@ describe("OpencodePlugin", () => { it.effect("uses configured provider env vars as credentials", () => withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - yield* plugin.add(pluginWithIntegrations(integrations)) - yield* integrations.update((editor) => { + yield* integrations.transform((editor) => { editor.method.update({ integrationID: Integration.ID.make("opencode"), method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] }, }) }) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -140,27 +215,31 @@ describe("OpencodePlugin", () => { it.effect("uses configured apiKey as credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode", { + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { apiKey: "configured" }, }, }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), }) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + catalog.provider.update(provider.id, (draft) => { + draft.request = provider.request + }) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -168,20 +247,25 @@ describe("OpencodePlugin", () => { it.effect("ignores non-opencode providers and models", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("openai") - catalog.provider.update(item.id, () => {}) - const paid = model("openai", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + yield* catalog.transform((catalog) => { + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) + yield* addPlugin() + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), ) @@ -191,28 +275,25 @@ describe("OpencodePlugin", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(1, 1)] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(10, 10)] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) }) const selected = yield* catalog.model.small(providerID) - expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano")) - }).pipe( - Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))), - ), + expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) + }), ) }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index df84e2487a2..11594915175 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -1,121 +1,112 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, model, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* OpenRouterPlugin.effect(host) +}) describe("OpenRouterPlugin", () => { it.effect("is registered so legacy OpenRouter behavior can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "openrouter", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))), ) it.effect("applies legacy referer headers only to openrouter", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api - item.request = openrouter.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({}) }), ) it.effect("creates an SDK only for the OpenRouter package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenRouterPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("openrouter", "openai/gpt-5"), - package: "@ai-sdk/openai-compatible", - options: { name: "openrouter" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openrouter" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@openrouter/ai-sdk-provider", + options: { name: "custom" }, + }) expect(result.sdk).toBeDefined() }), ) it.effect("filters OpenRouter's gpt-5 chat alias", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } }) catalog.provider.update(ProviderV2.ID.openai, () => {}) - for (const item of [ - model("openrouter", "openai/gpt-5-chat"), - model("openrouter", "openai/gpt-5"), - model("openai", "openai/gpt-5-chat"), - ]) { - catalog.model.update(item.providerID, item.id, () => {}) - } + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {}) + catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) }) + yield* addPlugin() - expect( - (yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled, - ).toBe(false) - expect( - (yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled, - ).toBe(true) - expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe( + false, + ) + expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true) }), ) it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( (yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))) - .enabled, + ?.enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index 444badd8564..a2a39692330 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -1,20 +1,51 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* PerplexityPlugin.effect(host) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("PerplexityPlugin", () => { it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(PerplexityPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -22,16 +53,16 @@ describe("PerplexityPlugin", () => { it.effect("ignores packages that are not the bundled Perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(PerplexityPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("perplexity", "sonar"), - package: "@ai-sdk/perplexity-compatible", - options: { name: "perplexity" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity-compatible", + options: { name: "perplexity" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -39,67 +70,51 @@ describe("PerplexityPlugin", () => { it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* plugin.add(PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, - {}, - ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* plugin.add(PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("custom-perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, }), + package: "@ai-sdk/perplexity", + options: { name: "custom-perplexity" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-perplexity", "sonar"), - package: "@ai-sdk/perplexity", - options: { name: "custom-perplexity" }, - }, - {}, - ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) it.effect("leaves Perplexity language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(PerplexityPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 565b9280ab9..5056c5dbb7e 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,10 +1,57 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { Npm } from "@opencode-ai/core/npm" import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" -import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const pluginWithNpm = { id: SapAICorePlugin.id, effect: SapAICorePlugin.effect.pipe(Effect.provide(npmLayer)) } +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href +const it = testEffect(PluginTestLayer) +const npm = Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), +}) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* SapAICorePlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm)) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + for (const [key, value] of Object.entries(vars)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + return previous + }), + effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) +} + +function model(providerID: string) { + return ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), + api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider }, + }) +} describe("SapAICorePlugin", () => { it.effect("copies serviceKey option into AICORE_SERVICE_KEY but keeps SDK options to deployment metadata", () => @@ -13,16 +60,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("sap-ai-core", "sap-model"), - package: fixtureProvider, - options: { name: "sap-ai-core", serviceKey: "service-key" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "service-key" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBe("service-key") expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) }), @@ -39,16 +83,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("sap-ai-core", "sap-model"), - package: fixtureProvider, - options: { name: "sap-ai-core", serviceKey: "option-service-key" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "option-service-key" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBe("env-service-key") expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) }), @@ -61,12 +102,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() expect(sdk.sdk.options).toEqual({}) }), @@ -76,17 +118,14 @@ describe("SapAICorePlugin", () => { it.effect("uses the callable SDK for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + const aisdk = yield* AISDK.Service + yield* addPlugin() const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { languageModel() { throw new Error("SAP AI Core should call the SDK directly") }, }) - const language = yield* plugin.trigger( - "aisdk.language", - { model: model("sap-ai-core", "sap-model"), sdk, options: {} }, - {}, - ) + const language = yield* aisdk.runLanguage({ model: model("sap-ai-core"), sdk, options: {} }) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) }), ) @@ -97,27 +136,20 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("openai", "sap-model"), - package: fixtureProvider, - options: { name: "openai", serviceKey: "service-key" }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const sdk = yield* aisdk.runSDK({ + model: model("openai"), + package: fixtureProvider, + options: { name: "openai", serviceKey: "service-key" }, + }) + const language = yield* aisdk.runLanguage({ + model: model("openai"), + sdk: () => { + throw new Error("SAP AI Core should ignore other providers") }, - {}, - ) - const language = yield* plugin.trigger( - "aisdk.language", - { - model: model("openai", "sap-model"), - sdk: () => { - throw new Error("SAP AI Core should ignore other providers") - }, - options: {}, - }, - {}, - ) + options: {}, + }) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() expect(sdk.sdk).toBeUndefined() expect(language.language).toBeUndefined() diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index ff5ec4ba451..cd67feb40e8 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -1,18 +1,50 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" -import { expectPluginRegistered, it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* SnowflakeCortexPlugin.effect(host) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} describe("SnowflakeCortexPlugin", () => { it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => Effect.sync(() => { - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "snowflake-cortex", - ) - const ids = ProviderPlugins.map((p) => p.id as string) + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex")) + const ids = ProviderPlugins.map((p) => p.id) expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) }), ) @@ -20,12 +52,16 @@ describe("SnowflakeCortexPlugin", () => { it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), + api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "openai" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -34,16 +70,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -53,20 +89,20 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { - name: "snowflake-cortex", - baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", - apiKey: "options-pat", - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + apiKey: "options-pat", }, - {}, - ) + }) expect(result.sdk).toBeDefined() }), ), @@ -76,16 +112,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -95,20 +131,20 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { - name: "snowflake-cortex", - baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", - token: "options-token", - }, + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + token: "options-token", }, - {}, - ) + }) expect(result.sdk).toBeDefined() }), ), @@ -118,27 +154,17 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const captured: Record[] = [] - yield* plugin.add(SnowflakeCortexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - captured.push({ ...evt.options }) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("snowflake-cortex", "claude-sonnet-4-6"), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) - expect(captured[0]?.includeUsage).toBe(true) + expect(result.options.includeUsage).toBe(true) }), ), ) diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index 3457c2ac822..56bf7c96429 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -1,19 +1,51 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* TogetherAIPlugin.effect(host) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("TogetherAIPlugin", () => { it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(TogetherAIPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -21,24 +53,27 @@ describe("TogetherAIPlugin", () => { it.effect("matches the old bundled provider package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(TogetherAIPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("togetherai", "model"), - package: "file:///tmp/@ai-sdk/togetherai-provider.js", - options: { name: "togetherai" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/@ai-sdk/togetherai-provider.js", + options: { name: "togetherai" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -46,47 +81,44 @@ describe("TogetherAIPlugin", () => { it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* plugin.add(TogetherAIPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, }), + package: "@ai-sdk/togetherai", + options: { name: "custom-togetherai" }, }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-togetherai", "model"), - package: "@ai-sdk/togetherai", - options: { name: "custom-togetherai" }, - }, - {}, - ) - - expect(observed).toEqual(["togetherai.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") }), ) it.effect("defaults language selection to sdk.languageModel with the model API ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("togetherai"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + type: "aisdk", + package: "test-provider", + }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(result.language).toBeUndefined() expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index ff4a922ab1e..3ed6c711c49 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -1,19 +1,51 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* VenicePlugin.effect(host) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("VenicePlugin", () => { it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VenicePlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "venice-ai-sdk-provider", + options: { name: "venice" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -21,49 +53,42 @@ describe("VenicePlugin", () => { it.effect("uses the model provider ID as the bundled Venice SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* plugin.add(VenicePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, }), + package: "venice-ai-sdk-provider", + options: { name: "custom-venice", apiKey: "test" }, }) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("custom-venice", "model"), - package: "venice-ai-sdk-provider", - options: { name: "custom-venice", apiKey: "test" }, - }, - {}, - ) expect(result.sdk).toBeDefined() - expect(observed).toEqual(["custom-venice.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") }), ) it.effect("only handles the bundled venice-ai-sdk-provider package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VenicePlugin) - const similar = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("venice", "model"), - package: "file:///tmp/venice-ai-sdk-provider.js", - options: { name: "venice" }, - }, - {}, - ) - const other = yield* plugin.trigger( - "aisdk.sdk", - { model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const similar = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/venice-ai-sdk-provider.js", + options: { name: "venice" }, + }) + const other = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "venice" }, + }) expect(similar.sdk).toBeUndefined() expect(other.sdk).toBeUndefined() }), @@ -72,13 +97,17 @@ describe("VenicePlugin", () => { it.effect("leaves Venice language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(VenicePlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index 0569df3d876..6fddec155c5 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -1,29 +1,36 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* VercelPlugin.effect(host) +}) describe("VercelPlugin", () => { it.effect("applies legacy lower-case referer headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("vercel", { - api: { type: "aisdk", package: "@ai-sdk/vercel" }, - request: { headers: { Existing: "1" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + provider.request.headers.Existing = "1" }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({ Existing: "1", "http-referer": "https://kilo.ai/", "x-title": "Kilo Code", @@ -33,32 +40,33 @@ describe("VercelPlugin", () => { it.effect("does not add legacy upper-case referer headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - }) - }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( + yield* catalog.transform((catalog) => + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + }), + ) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( "HTTP-Referer", ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty("X-Title") + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title") }), ) it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VercelPlugin) - const event = yield* plugin.trigger( - "aisdk.sdk", - { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } }, - {}, - ) + const aisdk = yield* AISDK.Service + yield* addPlugin() + const event = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), + api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, + }), + package: "@ai-sdk/vercel", + options: { name: "custom-vercel" }, + }) expect(event.sdk).toBeDefined() expect(event.sdk.languageModel("v0-1.0-md").provider).toBe("vercel.chat") }), @@ -66,12 +74,10 @@ describe("VercelPlugin", () => { it.effect("ignores non-Vercel providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) - expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({}) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {})) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index e505f8538a4..c4d1a5ece37 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -1,37 +1,61 @@ +import { AISDK } from "@opencode-ai/core/aisdk" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { fakeSelectorSdk } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) +const it = testEffect(PluginTestLayer) -const model = new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { - id: ModelV2.ID.make("grok-4"), - type: "aisdk", - package: "@ai-sdk/xai", - }, +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* XAIPlugin.effect(host) }) +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} + describe("XAIPlugin", () => { it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(XAIPlugin) + const aisdk = yield* AISDK.Service + yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { model, package: "@ai-sdk/openai-compatible", options: {} }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/openai-compatible", + options: {}, + }) - const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {}) + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/xai", + options: {}, + }) expect(ignored.sdk).toBeUndefined() expect(typeof result.sdk?.responses).toBe("function") @@ -41,52 +65,37 @@ describe("XAIPlugin", () => { it.effect("creates xAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] + const aisdk = yield* AISDK.Service + yield* addPlugin() - yield* plugin.add(XAIPlugin) - yield* plugin.add( - PluginV2.define({ - id: PluginV2.ID.make("xai-sdk-name-observer"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (!evt.sdk) return - providers.push(evt.sdk.responses("grok-4").provider) - }), - } - }), + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, }), - ) + package: "@ai-sdk/xai", + options: {}, + }) - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }), - package: "@ai-sdk/xai", - options: {}, - }, - {}, - ) - - expect(providers).toEqual(["xai.responses"]) + expect(result.sdk.responses("grok-4").provider).toBe("xai.responses") }), ) it.effect("uses responses with the model api.id for xAI language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(XAIPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:grok-4"]) expect(result.language).toBeDefined() @@ -96,18 +105,18 @@ describe("XAIPlugin", () => { it.effect("ignores non-xAI providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] - yield* plugin.add(XAIPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* addPlugin() + const result = yield* aisdk.runLanguage({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index c6a0c720e11..1967d49aa53 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -2,36 +2,45 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* ZenmuxPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("ZenmuxPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "zenmux", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))), ) it.effect("applies the exact legacy Zenmux headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } }) }) - const result = yield* catalog.provider.get(ProviderV2.ID.make("zenmux")) + yield* addPlugin() + const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))) expect(result.request.headers).toEqual({ "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code" }) expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) }), @@ -39,22 +48,20 @@ describe("ZenmuxPlugin", () => { it.effect("merges legacy Zenmux headers with existing headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers.Existing = "value" }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", @@ -64,25 +71,20 @@ describe("ZenmuxPlugin", () => { it.effect("lets configured Zenmux legacy headers override defaults", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) @@ -91,23 +93,15 @@ describe("ZenmuxPlugin", () => { it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("openrouter", { - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 63d028e4ec0..61bee856b9f 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -1,25 +1,18 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { AgentV2 } from "@opencode-ai/core/agent" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" import { SkillV2 } from "@opencode-ai/core/skill" -import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { testEffect } from "../lib/effect" +import { host } from "./host" -const it = testEffect( - SkillV2.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(SkillDiscovery.defaultLayer), - Layer.provideMerge(AgentV2.locationLayer), - ), -) +const it = testEffect(AppNodeBuilder.build(SkillV2.node)) describe("SkillPlugin.Plugin", () => { it.effect("registers the built-in customize-opencode skill", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill)) + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) expect(yield* skill.list()).toContainEqual( expect.objectContaining({ diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts new file mode 100644 index 00000000000..84af04a4df9 --- /dev/null +++ b/packages/core/test/plugin/variant.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { Catalog } from "@opencode-ai/core/catalog" +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 { ModelV2 } from "@opencode-ai/core/model" +import { VariantPlugin } from "@opencode-ai/core/plugin/variant" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Effect, Layer } from "effect" +import { location } from "../fixture/location" +import { testEffect } from "../lib/effect" +import { catalogHost, host } from "./host" + +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), +) +const it = testEffect(AppNodeBuilder.build(Catalog.node, [[Location.node, locationLayer]])) + +describe("VariantPlugin", () => { + it.effect("adds GLM 5.2 variants after catalog sources", () => + Effect.gen(function* () { + const service = yield* Catalog.Service + yield* service.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.opencode, (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } + }) + catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => { + model.api = { + id: ModelV2.ID.make("glm-5.2"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + } + }) + }) + yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) + + expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([ + expect.objectContaining({ id: "high", body: { reasoning_effort: "high" } }), + expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }), + ]) + }), + ) + + it.effect("keeps explicit variants over generated defaults", () => + Effect.gen(function* () { + const service = yield* Catalog.Service + yield* service.transform((catalog) => { + catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => { + model.api = { + id: ModelV2.ID.make("glm-5.2"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + } + model.variants = [{ id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} }] + }) + }) + yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) + + expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([ + expect.objectContaining({ id: "high", headers: { custom: "true" } }), + expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }), + ]) + }), + ) +}) diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts index 42736eb7d8a..1428c1b830f 100644 --- a/packages/core/test/policy.test.ts +++ b/packages/core/test/policy.test.ts @@ -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", () => { diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts new file mode 100644 index 00000000000..d44cffd849e --- /dev/null +++ b/packages/core/test/preload.ts @@ -0,0 +1,5 @@ +import path from "path" + +process.env.KILO_DB = ":memory:" +process.env.KILO_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json") +process.env.KILO_DISABLE_MODELS_FETCH = "true" diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index f8377f718aa..82b6dc4717a 100644 --- a/packages/core/test/process/process.test.ts +++ b/packages/core/test/process/process.test.ts @@ -5,10 +5,11 @@ import { tmpdir } from "node:os" import path from "node:path" import { Effect, Exit, Fiber, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppProcess } from "@opencode-ai/core/process" import { testEffect } from "../lib/effect" -const it = testEffect(AppProcess.defaultLayer) +const it = testEffect(LayerNode.compile(AppProcess.node)) const NODE = process.execPath const cmd = (...args: string[]) => ChildProcess.make(NODE, args) @@ -39,6 +40,22 @@ describe("AppProcess", () => { }), ) + it.effect( + "captures stdout and stderr in emission order", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const script = [ + 'process.stdout.write("out 1\\n")', + 'setTimeout(() => process.stderr.write("err 1\\n"), 10)', + 'setTimeout(() => process.stdout.write("out 2\\n"), 20)', + ].join(";") + const result = yield* svc.run(cmd("-e", script), { combineOutput: true }) + expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n") + expect(result.stdout.toString("utf8")).toBe("") + expect(result.stderr.toString("utf8")).toBe("") + }), + ) + it.effect( "non-zero exit returns RunResult; caller can require success", Effect.gen(function* () { @@ -61,6 +78,7 @@ describe("AppProcess", () => { if (reason && reason._tag === "Fail") { expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError) expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1) + expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)") } else { throw new Error("expected fail reason") } @@ -144,7 +162,7 @@ describe("AppProcess", () => { const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)` return Effect.gen(function* () { const svc = yield* AppProcess.Service - const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "1 second" })) + const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "250 millis" })) expect(Exit.isFailure(exit)).toBe(true) expect(yield* waitForFile(ready)).toMatch(/^\d+$/) expect(yield* waitForFile(settled)).toBe("settled") diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index 47f2176c376..d37f6302074 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -3,9 +3,10 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import { eq } from "drizzle-orm" -import { Effect, Fiber, Layer, Stream } from "effect" +import { Effect, Fiber, Stream } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AbsolutePath } from "@opencode-ai/core/schema" -import { FSUtil } from "@opencode-ai/core/fs-util" import { Git } from "@opencode-ai/core/git" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" @@ -16,17 +17,9 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer)) -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) -const copyLayer = ProjectCopy.layer.pipe( - Layer.provide(databaseLayer), - Layer.provide(directoriesLayer), - Layer.provide(eventLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, EventV2.node, ProjectDirectories.node])), ) -const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer)) function abs(input: string) { return AbsolutePath.make(input) diff --git a/packages/core/test/project-directories.test.ts b/packages/core/test/project-directories.test.ts index c1d2d8801f9..d91683cf422 100644 --- a/packages/core/test/project-directories.test.ts +++ b/packages/core/test/project-directories.test.ts @@ -1,17 +1,15 @@ import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" -import { EventV2 } from "@opencode-ai/core/event" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Project } from "@opencode-ai/core/project" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, directories)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectDirectories.node]))) const projectID = Project.ID.make("project-directories") const directory = AbsolutePath.make("/tmp/project-directories") diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 4938656d544..3cb60edcc64 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -2,31 +2,15 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ProjectV2 } from "@opencode-ai/core/project" -import { Database } from "@opencode-ai/core/database/database" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Git } from "@opencode-ai/core/git" import { AbsolutePath } from "@opencode-ai/core/schema" import { Hash } from "@opencode-ai/core/util/hash" -import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) -const it = testEffect( - Layer.mergeAll( - ProjectV2.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(directoriesLayer), - Layer.provide(databaseLayer), - ), - databaseLayer, - directoriesLayer, - ), -) +const it = testEffect(AppNodeBuilder.build(ProjectV2.node)) function remoteID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index a238ffb579c..899b4113f49 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -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" @@ -19,11 +21,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 @@ -80,6 +81,7 @@ const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* return { attachment, output, ended } }) +// kilocode_change start - preserve collected PTY output in timeout diagnostics const waitForOutput = (output: Queue.Queue, text: string) => { let received = "" const pull = Effect.gen(function* () { @@ -93,6 +95,7 @@ const waitForOutput = (output: Queue.Queue, text: string) => { }), ) } +// kilocode_change end describe("pty", () => { it.live("returns typed not found errors for missing sessions", () => @@ -284,8 +287,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( @@ -294,10 +298,9 @@ const configuredIt = testEffect( : [], ), }), - ), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - ), + ], + [Location.node, locationLayer], + ]), ) const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live diff --git a/packages/core/test/pty/ticket.test.ts b/packages/core/test/pty/ticket.test.ts index e36808fa2d9..10849f2f61b 100644 --- a/packages/core/test/pty/ticket.test.ts +++ b/packages/core/test/pty/ticket.test.ts @@ -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", () => diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts deleted file mode 100644 index c5f90e92c48..00000000000 --- a/packages/core/test/public-opencode.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import fs from "fs/promises" -import path from "path" -import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" -import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public" -import { tmpdir } from "./fixture/tmpdir" -import { testEffect } from "./lib/effect" - -const it = testEffect(OpenCode.layer) - -describe("public native OpenCode API", () => { - it.effect("exposes only the intentional Session capabilities", () => - Effect.gen(function* () { - const opencode = yield* OpenCode.Service - - expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"]) - - expect(Object.keys(opencode.sessions).sort()).toEqual([ - "context", - "create", - "events", - "get", - "interrupt", - "list", - "message", - "messages", - "prompt", - "switchModel", - ]) - expect(Session.ID.create()).toStartWith("ses_") - expect(Session.MessageID.create()).toStartWith("msg_") - expect(yield* opencode.sessions.list()).toBeArray() - yield* opencode.tools.register({ - public_tool: Tool.make({ - description: "Public tool", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), - }), - }) - }), - ) - - it.effect("switches to an available model and variant", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - yield* writeProvider(tmp.path) - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_available") - const model = ref({ variant: "fast" }) - yield* opencode.sessions.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), - }) - - yield* opencode.sessions.switchModel({ sessionID, model }) - - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model) - }), - ), - ), - ) - - it.effect("rejects missing and Location-disabled models without changing the Session", () => - Effect.acquireRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), - ).pipe( - Effect.flatMap(([available, disabled]) => - Effect.gen(function* () { - yield* writeProvider(available.path) - yield* writeProvider(disabled.path, true) - const opencode = yield* OpenCode.Service - const availableID = Session.ID.make("ses_public_switch_exact_available") - const disabledID = Session.ID.make("ses_public_switch_exact_disabled") - yield* opencode.sessions.create({ - id: availableID, - location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }), - }) - yield* opencode.sessions.create({ - id: disabledID, - location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }), - }) - - yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) }) - const disabledError = yield* opencode.sessions - .switchModel({ sessionID: disabledID, model: ref() }) - .pipe(Effect.flip) - const missingError = yield* opencode.sessions - .switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) }) - .pipe(Effect.flip) - - expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError) - expect(missingError).toBeInstanceOf(Session.ModelUnavailableError) - expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" })) - expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined() - }), - ), - ), - ) - - it.effect("rejects an unavailable variant without changing the Session", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - yield* writeProvider(tmp.path) - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_variant") - const selected = ref({ variant: "fast" }) - yield* opencode.sessions.create({ - id: sessionID, - location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }), - }) - yield* opencode.sessions.switchModel({ sessionID, model: selected }) - - const error = yield* opencode.sessions - .switchModel({ sessionID, model: ref({ variant: "unknown" }) }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(Session.VariantUnavailableError) - expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected) - }), - ), - ), - ) - - it.effect("preserves the typed not-found error for a missing Session", () => - Effect.gen(function* () { - const opencode = yield* OpenCode.Service - const sessionID = Session.ID.make("ses_public_switch_missing") - const error = yield* opencode.sessions - .switchModel({ - sessionID, - model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }), - }) - .pipe(Effect.flip) - - expect(error).toBeInstanceOf(Session.NotFoundError) - if (error instanceof Session.NotFoundError) expect(error.sessionID).toBe(sessionID) - }), - ) -}) - -const ref = (input: { id?: string; variant?: string } = {}) => - Schema.decodeUnknownSync(Model.Ref)({ - id: input.id ?? "chat", - providerID: "public-test", - variant: input.variant, - }) - -const writeProvider = (directory: string, disabled = false) => - Effect.promise(() => - fs.writeFile( - path.join(directory, "opencode.json"), - JSON.stringify({ - providers: { - "public-test": { - name: "Public test", - api: { type: "native", settings: {} }, - models: { - chat: { - disabled, - variants: [{ id: "fast" }], - }, - }, - }, - }, - }), - ), - ) diff --git a/packages/core/test/public-tool.test.ts b/packages/core/test/public-tool.test.ts deleted file mode 100644 index d3f444dd097..00000000000 --- a/packages/core/test/public-tool.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { Tool } from "@opencode-ai/core/public" -import { Effect } from "effect" - -describe("public Tool API", () => { - it("keeps the public registration capability narrow", () => { - const tools = { - register: () => Effect.void, - } satisfies Tool.Interface - - expect(Object.keys(tools)).toEqual(["register"]) - }) -}) diff --git a/packages/core/test/question.test.ts b/packages/core/test/question.test.ts index 57bf399669a..03d61a95650 100644 --- a/packages/core/test/question.test.ts +++ b/packages/core/test/question.test.ts @@ -1,15 +1,14 @@ import { describe, expect } from "bun:test" import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" -import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" import { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, questions)) +const questions = AppNodeBuilder.build(LayerNode.group([EventV2.node, QuestionV2.node])) +const it = testEffect(questions) const sessionID = SessionV2.ID.make("ses_question_test") const question: QuestionV2.Info = { diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 5e1aba1923c..2a317af2cac 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -1,12 +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 { PluginBoot } from "@opencode-ai/core/plugin/boot" 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) => + AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]]) + describe("ReferenceGuidance", () => { it.effect("lists available references in the system context", () => Effect.gen(function* () { @@ -18,25 +21,25 @@ describe("ReferenceGuidance", () => { expect(generation.baseline).toContain("/docs") expect(generation.baseline).toContain("Use for product documentation") }).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: new Reference.LocalSource({ - 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", + }), }), - }), - ]), - }), + ]), + }), + ), ), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) @@ -45,11 +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([]) })), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), - ), + }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))), ) it.effect("omits references without descriptions", () => @@ -58,20 +57,20 @@ 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: new Reference.LocalSource({ 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") }), + }), + ]), + }), + ), ), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) }) diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index 1beb150b33e..a838fdca059 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -1,37 +1,31 @@ import { describe, expect } from "bun:test" import { Effect, Exit, Layer, Scope } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AbsolutePath } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { Reference } from "@opencode-ai/core/reference" import { Repository } from "@opencode-ai/core/repository" import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" const cache = Layer.mock(RepositoryCache.Service, { ensure: () => Effect.die("unexpected Git materialization"), }) -// kilocode_change start -const events = Layer.mock(EventV2.Service)({ - publish: (definition, data) => - Effect.succeed({ id: EventV2.ID.make("evt_reference_test"), type: definition.type, data }), -}) -// kilocode_change end +const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]]) describe("Reference", () => { it.effect("registers normalized sources for the owning scope", () => Effect.gen(function* () { const references = yield* Reference.Service const scope = yield* Scope.make() - const update = yield* references.transform().pipe(Effect.provideService(Scope.Scope, scope)) const path = AbsolutePath.make("/docs") - const source = new Reference.LocalSource({ + const source = Reference.LocalSource.make({ type: "local", path, description: "Use for API documentation", hidden: true, }) - yield* update((editor) => editor.add("docs", source)) + yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope)) expect(yield* references.list()).toEqual([ new Reference.Info({ name: "docs", path, description: "Use for API documentation", hidden: true, source }), @@ -39,21 +33,15 @@ describe("Reference", () => { yield* Scope.close(scope, Exit.void) expect(yield* references.list()).toEqual([]) - }).pipe( - Effect.provide(Reference.layer), - Effect.provide(cache), - Effect.provide(events), // kilocode_change - Effect.provide(Global.defaultLayer), - ), + }).pipe(Effect.provide(referenceLayer)), ) it.effect("derives Git paths without exposing cache operations", () => Effect.gen(function* () { const references = yield* Reference.Service - const update = yield* references.transform() const repository = Repository.parseRemote("owner/repo") - const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" }) - yield* update((editor) => editor.add("sdk", source)) + const source = Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "main" }) + yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ new Reference.Info({ @@ -62,26 +50,19 @@ describe("Reference", () => { source, }), ]) - }).pipe( - Effect.scoped, - Effect.provide(Reference.layer), - Effect.provide(cache), - Effect.provide(events), // kilocode_change - Effect.provide(Global.defaultLayer), - ), + }).pipe(Effect.scoped, Effect.provide(referenceLayer)), ) it.effect("preserves configured Git descriptions", () => Effect.gen(function* () { const references = yield* Reference.Service - const update = yield* references.transform() const repository = Repository.parseRemote("owner/repo") - const source = new Reference.GitSource({ + const source = Reference.GitSource.make({ type: "git", repository: "owner/repo", description: "Use for SDK implementation details", }) - yield* update((editor) => editor.add("sdk", source)) + yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ new Reference.Info({ @@ -91,38 +72,6 @@ describe("Reference", () => { source, }), ]) - }).pipe( - Effect.scoped, - Effect.provide(Reference.layer), - Effect.provide(cache), - // kilocode_change start - Effect.provide(events), - Effect.provide(Global.defaultLayer), - ), - ) - - it.effect("replaces sources without a scoped transform", () => - Effect.gen(function* () { - const references = yield* Reference.Service - const update = yield* references.transform() - const stale = new Reference.LocalSource({ type: "local", path: AbsolutePath.make("/stale") }) - const current = new Reference.LocalSource({ type: "local", path: AbsolutePath.make("/current") }) - yield* update((editor) => editor.add("stale", stale)) - // kilocode_change end - - yield* references.replace([["current", current]]) // kilocode_change - - // kilocode_change start - expect(yield* references.list()).toEqual([ - new Reference.Info({ name: "current", path: AbsolutePath.make("/current"), source: current }), - ]) - }).pipe( - Effect.scoped, - Effect.provide(Reference.layer), - Effect.provide(cache), - Effect.provide(events), - // kilocode_change end - Effect.provide(Global.defaultLayer), - ), + }).pipe(Effect.scoped, Effect.provide(referenceLayer)), ) }) diff --git a/packages/core/test/repository-cache.test.ts b/packages/core/test/repository-cache.test.ts index a99daea8e29..2dd0ce25028 100644 --- a/packages/core/test/repository-cache.test.ts +++ b/packages/core/test/repository-cache.test.ts @@ -3,12 +3,11 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer } from "effect" -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 { Global } from "@opencode-ai/core/global" import { Repository } from "@opencode-ai/core/repository" import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { git, gitRemote } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -89,15 +88,9 @@ describe("RepositoryCache", () => { }) function cacheLayer(root: string) { - const dependencies = Layer.mergeAll( - Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }), - FSUtil.defaultLayer, - ) - return RepositoryCache.layer.pipe( - Layer.provide(EffectFlock.layer.pipe(Layer.provide(dependencies))), - Layer.provide(Git.defaultLayer), - Layer.provide(dependencies), - ) + return AppNodeBuilder.build(RepositoryCache.node, [ + [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })], + ]) } function withRemote(body: (fixture: Awaited>) => Effect.Effect) { diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index b8e19e39ea2..d819f8f2f87 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -2,12 +2,13 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { RelativePath } from "@opencode-ai/core/schema" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect(Ripgrep.defaultLayer) +const it = testEffect(LayerNode.compile(Ripgrep.node)) describe("Ripgrep", () => { it.live("keeps ignored files out of catch-all find results", () => diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 3551ec52f3c..4688ede82d1 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -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" @@ -25,8 +27,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { testEffect } from "./lib/effect" import { tmpdir } from "./fixture/tmpdir" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ @@ -35,37 +35,19 @@ const projects = Layer.succeed( commit: () => Effect.void, }), ) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - Layer.provide(projects), - Layer.provide(SessionExecution.noopLayer), -) const it = testEffect( - Layer.mergeAll(database, events, projects, projector, store, 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") }) const id = SessionV2.ID.create() describe("SessionV2.create", () => { - it.effect("derives stable namespaced external IDs", () => - Effect.sync(() => { - const input = { namespace: "opencord.agent-thread", key: "thread-1" } - - expect(SessionV2.ID.fromExternal(input)).toBe(SessionV2.ID.fromExternal(input)) - expect(SessionV2.ID.fromExternal(input)).toMatch(/^ses_[a-f0-9]{64}$/) - expect(SessionV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe( - SessionV2.ID.fromExternal(input), - ) - expect(SessionV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe( - SessionV2.ID.fromExternal({ namespace: "a", key: "b:c" }), - ) - }), - ) - it.effect("creates a fresh projected session when the ID is omitted", () => Effect.gen(function* () { const session = yield* SessionV2.Service @@ -214,14 +196,14 @@ describe("SessionV2.create", () => { const events = yield* EventV2.Service const { db } = yield* Database.Service const created = yield* session.create({ location }) - yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false }) + yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false }) yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER) expect( Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)), ).toMatchObject([ - { cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } }, - { cursor: 2, event: { type: "session.next.prompt.promoted" } }, + { durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } }, + { durable: { seq: 2 }, type: "session.next.prompted" }, ]) }), ) @@ -234,7 +216,7 @@ describe("SessionV2.create", () => { const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location }) const admitted = yield* session.prompt({ sessionID: created.id, - prompt: new Prompt({ text: "Replay lifecycle" }), + prompt: Prompt.make({ text: "Replay lifecycle" }), resume: false, }) yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER) @@ -257,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 @@ -304,10 +287,10 @@ describe("SessionV2.create", () => { .pipe(Effect.orDie)).map((event) => [event.seq, event.type]), ).toEqual([ [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], - [1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)], - [2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)], + [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))) }), ) @@ -336,7 +319,34 @@ describe("SessionV2.create", () => { expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill") - expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent") + }), + ) + + it.effect("switches the selected agent through the durable Session event", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + + yield* session.switchAgent({ sessionID: created.id, agent: "plan" }) + + expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) + expect( + Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), + ).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }]) + }), + ) + + it.effect("rejects an agent switch for a missing Session", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const missing = SessionV2.ID.make("ses_missing_agent_switch") + + expect( + yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe( + Effect.flip, + Effect.map((error) => error._tag), + ), + ).toBe("Session.NotFoundError") }), ) @@ -355,11 +365,11 @@ describe("SessionV2.create", () => { expect(yield* session.get(created.id)).toMatchObject({ model }) expect( Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ event: { type: "session.next.model.switched", data: { model } } }]) + ).toMatchObject([{ type: "session.next.model.switched", data: { model } }]) }), ) - it.effect("persists repeated switches as distinct durable Session events", () => + it.effect("ignores a model switch when the selected model is unchanged", () => Effect.gen(function* () { const session = yield* SessionV2.Service const created = yield* session.create({ location }) @@ -371,11 +381,29 @@ describe("SessionV2.create", () => { const { db } = yield* Database.Service expect( yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie), - ).toHaveLength(3) + ).toHaveLength(2) expect(yield* session.get(created.id)).toMatchObject({ model }) }), ) + it.effect("treats an omitted variant as the default variant", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }) + const created = yield* session.create({ location, model }) + + yield* session.switchModel({ + sessionID: created.id, + model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }), + }) + + const { db } = yield* Database.Service + expect( + yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie), + ).toHaveLength(1) + }), + ) + it.effect("rejects a model switch for a missing Session", () => Effect.gen(function* () { const session = yield* SessionV2.Service diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts new file mode 100644 index 00000000000..c67f776d293 --- /dev/null +++ b/packages/core/test/session-history.test.ts @@ -0,0 +1,165 @@ +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 { ProjectV2 } 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 { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { testEffect } from "./lib/effect" + +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +const it = testEffect( + 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") }) + +const GapEvent = EventV2.define({ + type: "test.session.history.gap", + durable: { aggregate: "sessionID", version: 1 }, + schema: { sessionID: SessionV2.ID, value: Schema.String }, +}) + +describe("SessionV2.history", () => { + it.effect("returns an exhausted page for a migrated Session with no event sequence", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const session = yield* SessionV2.Service + const sessionID = SessionV2.ID.make("ses_empty_history") + yield* db + .insert(ProjectTable) + .values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: ProjectV2.ID.global, + slug: "empty-history", + directory: "/project", + title: "Empty history", + version: "test", + }) + .run() + + const first = yield* session.history({ sessionID, limit: 10 }) + + expect(first).toEqual({ events: [], hasMore: false }) + }), + ) + + it.effect("treats after as an exclusive aggregate sequence", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 }) + + expect(page.events.map((event) => event.durable?.seq)).toEqual([2]) + expect(page.hasMore).toBe(false) + }), + ) + + it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + yield* session.switchAgent({ sessionID: created.id, agent: "three" }) + + const first = yield* session.history({ sessionID: created.id, limit: 2 }) + const after = first.events.at(-1)?.durable?.seq + const second = yield* session.history({ + sessionID: created.id, + after, + limit: 2, + }) + const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq) + + expect(first.hasMore).toBe(true) + expect(second.hasMore).toBe(false) + expect(sequence).toEqual([1, 3, 4]) + expect(new Set(sequence).size).toBe(sequence.length) + }), + ) + + it.effect("includes events committed between pages", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const first = yield* session.history({ sessionID: created.id, limit: 1 }) + yield* session.switchAgent({ sessionID: created.id, agent: "later" }) + const second = yield* session.history({ + sessionID: created.id, + after: first.events.at(-1)?.durable?.seq, + limit: 10, + }) + + expect(first.hasMore).toBe(true) + expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3]) + expect(second.hasMore).toBe(false) + }), + ) + + it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const exact = yield* session.history({ sessionID: created.id, limit: 2 }) + const oneMore = yield* session.history({ sessionID: created.id, limit: 1 }) + const exhausted = yield* session.history({ + sessionID: created.id, + after: oneMore.events.at(-1)?.durable?.seq, + limit: 1, + }) + + expect(exact.events).toHaveLength(2) + expect(exact.hasMore).toBe(false) + expect(oneMore.events).toHaveLength(1) + expect(oneMore.hasMore).toBe(true) + expect(exhausted.events).toHaveLength(1) + expect(exhausted.hasMore).toBe(false) + }), + ) + + it.effect("fails with NotFoundError for a missing Session", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip) + + expect(error._tag).toBe("Session.NotFoundError") + }), + ) +}) diff --git a/packages/core/test/session-logging.test.ts b/packages/core/test/session-logging.test.ts deleted file mode 100644 index 3d6cff2e4e0..00000000000 --- a/packages/core/test/session-logging.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Cause, Effect, Logger } from "effect" -import { logFailure } from "@opencode-ai/core/session/logging" -import { SessionSchema } from "@opencode-ai/core/session/schema" - -describe("Session logging", () => { - for (const message of ["Failed to drain Session", "Failed to wake Session"] as const) { - test(`renders the cause for ${message}`, async () => { - const entries: Array> = [] - const logger = Logger.formatStructured.pipe( - Logger.map((entry): void => { - entries.push(entry) - }), - ) - - await logFailure( - message, - SessionSchema.ID.make("session-123"), - Cause.fail({ _tag: "SessionFailure", detail: { code: "nested-code" } }), - ).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise) - - expect(entries).toHaveLength(1) - expect(entries[0]?.message).toBe(message) - expect(entries[0]?.annotations).toEqual({ sessionID: "session-123" }) - expect(entries[0]?.cause).toContain("SessionFailure") - expect(entries[0]?.cause).toContain("nested-code") - expect(entries[0]?.cause).not.toContain("[Object") - }) - } -}) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 76c4d16a0a0..e02c3302051 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -1,7 +1,9 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Schema } from "effect" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { ModelV2 } from "@opencode-ai/core/model" @@ -17,15 +19,13 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" -import { SessionStore } from "@opencode-ai/core/session/store" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import * as StoredMessage from "@opencode-ai/core/kilocode/session-message" // kilocode_change import { testEffect } from "./lib/effect" +import { Snapshot } from "@opencode-ai/core/snapshot" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) +const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]]) const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } @@ -40,11 +40,63 @@ const assistantRow = ( id: _, type, ...data - } = encodeMessage(new SessionMessage.Assistant({ id, type: "assistant", agent: "build", model, content: [], time })) + } = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time })) return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } } describe("SessionProjector", () => { + it.effect("projects staged, cleared, and committed reverts", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + const boundary = SessionMessage.ID.make("msg_boundary") + yield* db + .insert(SessionMessageTable) + .values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)]) + .run() + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] }, + }) + expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ + messageID: boundary, + snapshot: "tree", + files: [], + }) + yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID, timestamp: DateTime.makeUnsafe(2) }) + expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull() + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + timestamp: DateTime.makeUnsafe(3), + revert: { messageID: boundary, files: [] }, + }) + yield* events.publish(SessionEvent.RevertEvent.Committed, { + sessionID, + messageID: boundary, + timestamp: DateTime.makeUnsafe(4), + }) + expect( + (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), + ).toEqual([boundary]) + }), + ) + it.effect("orders projected messages and context by durable aggregate sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service @@ -73,7 +125,7 @@ describe("SessionProjector", () => { sessionID, messageID: SessionMessage.ID.make("msg_first"), timestamp: created, - prompt: new Prompt({ text: "first" }), + prompt: Prompt.make({ text: "first" }), delivery: "steer", }, { id: EventV2.ID.make("evt_z") }, @@ -84,7 +136,7 @@ describe("SessionProjector", () => { sessionID, messageID: SessionMessage.ID.make("msg_second"), timestamp: created, - prompt: new Prompt({ text: "second" }), + prompt: Prompt.make({ text: "second" }), delivery: "steer", }, { id: EventV2.ID.make("evt_a") }, @@ -111,20 +163,10 @@ describe("SessionProjector", () => { expect( (yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)), ).toEqual(["first", "second"]) - }).pipe( - Effect.provide( - SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(Project.defaultLayer), - Layer.provide(SessionStore.layer.pipe(Layer.provide(database))), - Layer.provide(SessionExecution.noopLayer), - ), - ), - ), + }).pipe(Effect.provide(sessionsLayer)), ) - it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () => + it.effect("marks an inbox row promoted with the Prompted event sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -146,24 +188,25 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_admitted") - yield* SessionInput.admit(db, events, { + const admitted = yield* SessionInput.admit(db, events, { id, sessionID, - prompt: new Prompt({ text: "promote me" }), + prompt: Prompt.make({ text: "promote me" }), delivery: "steer", }) + if (!admitted) return yield* Effect.die("Prompt admission failed") - const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, { + const event = yield* events.publish(SessionEvent.Prompted, { sessionID, - timestamp: created, + timestamp: admitted.timeCreated, messageID: id, - prompt: new Prompt({ text: "promote me" }), - timeCreated: created, + prompt: Prompt.make({ text: "promote me" }), + delivery: "steer", }) expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ promoted_seq: event.seq }) + ).toMatchObject({ promoted_seq: event.durable?.seq }) }), ) @@ -432,137 +475,9 @@ describe("SessionProjector", () => { }), ) - it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "test", - directory: "/project", - title: "test", - version: "test", - }) - .run() - .pipe(Effect.orDie) - const events = yield* EventV2.Service - const id = SessionMessage.ID.make("msg_conflict") - yield* SessionInput.admit(db, events, { - id, - sessionID, - prompt: new Prompt({ text: "admitted" }), - delivery: "steer", - }) - - const exit = yield* events - .publish(SessionEvent.Prompted, { - sessionID, - messageID: id, - timestamp: created, - prompt: new Prompt({ text: "different" }), - delivery: "steer", - }) - .pipe(Effect.exit) - - expect(String(exit)).toContain("SessionInput.LifecycleConflict") - expect( - yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ promoted_seq: null }) - }), - ) - - it.effect("rejects an assistant message ID that conflicts with an admitted inbox row", () => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "test", - directory: "/project", - title: "test", - version: "test", - }) - .run() - .pipe(Effect.orDie) - const events = yield* EventV2.Service - const id = SessionMessage.ID.make("msg_conflict") - yield* SessionInput.admit(db, events, { - id, - sessionID, - prompt: new Prompt({ text: "admitted" }), - delivery: "steer", - }) - - const exit = yield* events - .publish(SessionEvent.Step.Started, { - sessionID, - timestamp: created, - assistantMessageID: id, - agent: "build", - model, - }) - .pipe(Effect.exit) - - expect(String(exit)).toContain("SessionInput.LifecycleConflict") - expect( - yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie), - ).toBeUndefined() - }), - ) - - it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "test", - directory: "/project", - title: "test", - version: "test", - }) - .run() - .pipe(Effect.orDie) - const events = yield* EventV2.Service - const id = SessionMessage.ID.make("msg_delivery_conflict") - const prompt = new Prompt({ text: "admitted" }) - yield* SessionInput.admit(db, events, { id, sessionID, prompt, delivery: "queue" }) - - const exit = yield* events - .publish(SessionEvent.Prompted, { sessionID, messageID: id, timestamp: created, prompt, delivery: "steer" }) - .pipe(Effect.exit) - - expect(String(exit)).toContain("SessionInput.LifecycleConflict") - expect( - yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), - ).toMatchObject({ delivery: "queue", promoted_seq: null }) - }), - ) - it.effect("does not revive a stale incomplete in-memory assistant projection", () => Effect.gen(function* () { - const stale = new SessionMessage.Assistant({ + const stale = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", @@ -570,7 +485,7 @@ describe("SessionProjector", () => { content: [], time: { created }, }) - const completed = new SessionMessage.Assistant({ + const completed = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", @@ -694,15 +609,15 @@ describe("SessionProjector", () => { Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), ) expect(messages).toEqual([ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", model, - content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })], + content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }), - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 52969dd90b5..4389eaf90e6 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -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" @@ -18,42 +20,34 @@ import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode- import { SessionStore } from "@opencode-ai/core/session/store" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const executionCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = [] -const interruptSeqs: Array = [] const wakeCalls: SessionV2.ID[] = [] -const wakeSeqs: Array = [] +const activeSessions = new Set() const execution = Layer.succeed( SessionExecution.Service, SessionExecution.Service.of({ + active: Effect.sync(() => new Set(activeSessions)), resume: (sessionID) => Effect.sync(() => { executionCalls.push(sessionID) }), - interrupt: (sessionID, seq) => + interrupt: (sessionID) => Effect.sync(() => { interruptCalls.push(sessionID) - interruptSeqs.push(seq) }), - wake: (sessionID, seq) => + wake: (sessionID) => Effect.sync(() => { wakeCalls.push(sessionID) - wakeSeqs.push(seq) }), }), ) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - Layer.provide(Project.defaultLayer), - Layer.provide(execution), +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [[SessionExecution.node, execution]], + ), ) -const it = testEffect(Layer.mergeAll(database, events, projector, store, execution, sessions)) const sessionID = SessionV2.ID.make("ses_prompt_test") const messageID = SessionMessage.ID.create() @@ -106,6 +100,13 @@ const eventCount = (type: string) => // kilocode_change - no durable interrupt lookup: released database readers cannot decode that event type. describe("SessionV2.prompt", () => { + it.effect("exposes the execution registry", () => + Effect.gen(function* () { + activeSessions.add(sessionID) + expect(Array.from(yield* (yield* SessionV2.Service).active)).toEqual([sessionID]) + }).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))), + ) + it.effect("delegates execution continuation through SessionExecution", () => Effect.gen(function* () { yield* setup @@ -118,19 +119,14 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("delegates interruption through SessionExecution", () => + it.effect("delegates process-local interruption through SessionExecution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service interruptCalls.length = 0 - interruptSeqs.length = 0 yield* session.interrupt(sessionID) expect(interruptCalls).toEqual([sessionID]) - expect(interruptSeqs).toHaveLength(1) - expect(typeof interruptSeqs[0]).toBe("number") - expect(interruptSeqs[0]).toBe(-1) // kilocode_change - ephemeral interrupts do not advance durable storage - expect(yield* eventCount("session.next.interrupt.requested.1")).toBe(0) // kilocode_change expect(yield* session.messages({ sessionID })).toEqual([]) }), ) @@ -139,11 +135,9 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { const session = yield* SessionV2.Service interruptCalls.length = 0 - interruptSeqs.length = 0 yield* session.interrupt(SessionV2.ID.make("ses_missing")) expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")]) - expect(interruptSeqs).toEqual([undefined]) }), ) @@ -154,7 +148,7 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, }) @@ -169,7 +163,28 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("streams durable Session events after an aggregate cursor", () => + it.effect("resolves attachment MIME before admission", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + + const message = yield* session.prompt({ + sessionID, + prompt: { + text: "Inspect this image", + files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }], + }, + resume: false, + }) + + expect(message.prompt.files).toEqual([ + { uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" }, + ]) + expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) + }), + ) + + it.effect("streams durable Session events after an aggregate sequence", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -178,22 +193,24 @@ describe("SessionV2.prompt", () => { const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) const streamed = Array.from(yield* Fiber.join(fiber)) - expect(streamed.map((event) => [event.cursor, event.event.type])).toEqual([ - [EventV2.Cursor.make(0), "session.next.prompt.admitted"], - [EventV2.Cursor.make(1), "session.next.prompt.admitted"], - [EventV2.Cursor.make(2), "session.next.prompt.promoted"], - [EventV2.Cursor.make(3), "session.next.prompt.promoted"], + expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([ + [0, "session.next.prompt.admitted"], + [1, "session.next.prompt.admitted"], + [2, "session.next.prompted"], + [3, "session.next.prompted"], ]) expect( Array.from( - yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect), - ).map((event) => [event.cursor, event.event.type]), - ).toEqual([[EventV2.Cursor.make(1), "session.next.prompt.admitted"]]) + yield* session + .events({ sessionID, after: streamed[0]!.durable?.seq }) + .pipe(Stream.take(1), Stream.runCollect), + ).map((event) => [event.durable?.seq, event.type]), + ).toEqual([[1, "session.next.prompt.admitted"]]) }), ) @@ -203,7 +220,7 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service const message = yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, }) @@ -222,7 +239,7 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - const input = { sessionID, prompt: new Prompt({ text: "Fix the failing tests" }), resume: false } + const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false } const first = yield* session.prompt(input) const second = yield* session.prompt(input) @@ -240,7 +257,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, } @@ -260,7 +277,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: new Prompt({ text: "Recover committed prompt" }), + prompt: Prompt.make({ text: "Recover committed prompt" }), resume: false, } const first = yield* session.prompt(input) @@ -281,13 +298,13 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, id: messageID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), }) const failure = yield* session .prompt({ sessionID, id: messageID, - prompt: new Prompt({ text: "Delete the failing tests" }), + prompt: Prompt.make({ text: "Delete the failing tests" }), resume: false, }) .pipe(Effect.flip) @@ -306,14 +323,14 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, }) const failure = yield* session .prompt({ id: messageID, sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), delivery: "queue", resume: false, }) @@ -330,7 +347,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, } @@ -339,7 +356,7 @@ describe("SessionV2.prompt", () => { expect(messages[1]).toEqual(messages[0]) expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admittedCount).toBe(1) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1) }), ) @@ -349,7 +366,7 @@ describe("SessionV2.prompt", () => { const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Promote once" }), resume: false }) + yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false }) yield* Effect.all( [ @@ -359,7 +376,7 @@ describe("SessionV2.prompt", () => { { concurrency: "unbounded" }, ) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Promote once" }, @@ -367,15 +384,15 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("promotes steers only through the captured aggregate cutoff", () => + it.effect("promotes steers only through the captured inbox cutoff", () => Effect.gen(function* () { yield* setup const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service - const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false }) - const cutoff = yield* SessionInput.latestSeq(db, sessionID) - const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false }) + const first = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Before cutoff" }), resume: false }) + const cutoff = first.admittedSeq + const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, cutoff) @@ -384,14 +401,19 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("reprojects one pending lifecycle without scheduling execution", () => + it.effect("reprojects pending inbox input without scheduling execution", () => Effect.gen(function* () { yield* setup const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service wakeCalls.length = 0 - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Replay pending" }), resume: false }) + yield* session.prompt({ + id: messageID, + sessionID, + prompt: Prompt.make({ text: "Replay pending" }), + resume: false, + }) const recorded = yield* db .select() .from(EventTable) @@ -427,7 +449,7 @@ describe("SessionV2.prompt", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - const prompt = new Prompt({ text: "Historical prompt" }) + const prompt = Prompt.make({ text: "Historical prompt" }) yield* events.publish(SessionEvent.Prompted, { sessionID, messageID, @@ -448,7 +470,7 @@ describe("SessionV2.prompt", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - const prompt = new Prompt({ text: "Historical queued prompt" }) + const prompt = Prompt.make({ text: "Historical queued prompt" }) yield* events.publish(SessionEvent.Prompted, { sessionID, messageID, @@ -464,58 +486,6 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("rejects an input ID already used by a durable non-prompt event", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* events.publish(SessionEvent.Synthetic, { - sessionID, - messageID, - timestamp: yield* DateTime.now, - text: "Collision", - }) - - const failure = yield* session - .prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false }) - .pipe(Effect.flip) - - expect(failure._tag).toBe("Session.PromptConflictError") - expect(yield* admitted(messageID)).toBeUndefined() - }), - ) - - it.effect("rejects a durable event ID reserved by an admitted prompt without poisoning promotion", () => - Effect.gen(function* () { - yield* setup - const { db } = yield* Database.Service - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const prompt = new Prompt({ text: "Reserved prompt" }) - yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) - - const failure = yield* events - .publish(SessionEvent.Synthetic, { - sessionID, - messageID, - timestamp: yield* DateTime.now, - text: "Conflicting synthetic", - }) - .pipe(Effect.catchDefect(Effect.succeed)) - - expect(String(failure)).toContain("SessionInput.LifecycleConflict") - expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq") - expect(yield* session.messages({ sessionID })).toEqual([]) - - yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) - - expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) - expect(yield* session.messages({ sessionID })).toMatchObject([ - { id: messageID, type: "user", text: "Reserved prompt" }, - ]) - }), - ) - it.effect("rejects reuse of one globally unique message ID across sessions", () => Effect.gen(function* () { yield* setup @@ -535,7 +505,7 @@ describe("SessionV2.prompt", () => { .onConflictDoNothing() .run() .pipe(Effect.orDie) - const prompt = new Prompt({ text: "Fix the failing tests" }) + const prompt = Prompt.make({ text: "Fix the failing tests" }) yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) const failure = yield* session @@ -546,19 +516,38 @@ describe("SessionV2.prompt", () => { }), ) + it.effect("rejects a prompt ID already used by visible Session history", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Synthetic, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + text: "Existing history", + }) + + const failure = yield* session + .prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false }) + .pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID }) + expect(yield* admitted(messageID)).toBeUndefined() + }), + ) + it.effect("starts execution by default after recording the prompt", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service executionCalls.length = 0 wakeCalls.length = 0 - wakeSeqs.length = 0 - const admitted = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) - expect(wakeSeqs).toEqual([admitted.admittedSeq]) }), ) @@ -568,17 +557,15 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service executionCalls.length = 0 wakeCalls.length = 0 - wakeSeqs.length = 0 - const admitted = yield* session.prompt({ + yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Run explicitly" }), + prompt: Prompt.make({ text: "Run explicitly" }), resume: true, }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) - expect(wakeSeqs).toEqual([admitted.admittedSeq]) }), ) @@ -588,13 +575,11 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service executionCalls.length = 0 wakeCalls.length = 0 - wakeSeqs.length = 0 - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([]) - expect(wakeSeqs).toEqual([]) }), ) }) diff --git a/packages/core/test/session-run-coordinator.test.ts b/packages/core/test/session-run-coordinator.test.ts index 39fb5779d3a..dfbeda664c5 100644 --- a/packages/core/test/session-run-coordinator.test.ts +++ b/packages/core/test/session-run-coordinator.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { testEffect } from "./lib/effect" @@ -22,14 +22,39 @@ describe("SessionRunCoordinator", () => { expect(runs).toBe(1) yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) expect(runs).toBe(1) }), ), ) - it.effect("starts a drain when woken while idle", () => + it.effect("joins a wake-started execution without forcing a successor", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make() + const gate = yield* Deferred.make() + const forces: boolean[] = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: (_key, force) => + Effect.sync(() => forces.push(force)).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Deferred.await(gate)), + ), + }) + + yield* coordinator.wake("session") + yield* Deferred.await(started) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Effect.yieldNow + yield* Deferred.succeed(gate, undefined) + yield* Fiber.join(resumed) + + expect(forces).toEqual([false]) + }), + ), + ) + + it.effect("starts execution when woken while idle", () => Effect.scoped( Effect.gen(function* () { const drained = yield* Deferred.make() @@ -41,632 +66,117 @@ describe("SessionRunCoordinator", () => { ), ) - it.effect("does nothing when interrupted while idle", () => - Effect.scoped( - Effect.gen(function* () { - const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) - - yield* coordinator.interrupt("session") - }), - ), - ) - - it.effect("suppresses stale wakes after an idle interrupt boundary", () => - Effect.scoped( - Effect.gen(function* () { - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => runs++) }) - - yield* coordinator.interrupt("session", 2) - yield* coordinator.wake("session", 1) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(0) - - yield* coordinator.wake("session", 3) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(1) - }), - ), - ) - - it.effect("does not interrupt a wake newer than the interrupt boundary", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const gate = yield* Deferred.make() - const interrupted = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Deferred.await(gate)), - Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), - ), - }) - - yield* coordinator.wake("session", 3) - yield* Deferred.await(started) - yield* coordinator.interrupt("session", 2) - expect(yield* Deferred.isDone(interrupted)).toBeFalse() - yield* Deferred.succeed(gate, undefined) - yield* coordinator.awaitIdle("session") - }), - ), - ) - - it.effect("preserves a queued wake newer than the interrupt boundary", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session", 1) - yield* Deferred.await(firstStarted) - yield* coordinator.wake("session", 3) - yield* coordinator.interrupt("session", 2) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session").pipe(Effect.exit) - - expect(runs).toBe(2) - }), - ), - ) - - it.effect("interrupts only the requested key", () => + it.effect("snapshots only active executions", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() const secondStarted = yield* Deferred.make() + const firstGate = yield* Deferred.make() const secondGate = yield* Deferred.make() - const secondInterrupted = yield* Deferred.make() const coordinator = yield* SessionRunCoordinator.make({ drain: (key: string) => - key === "first" - ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Deferred.succeed(secondStarted, undefined).pipe( - Effect.andThen(Deferred.await(secondGate)), - Effect.onInterrupt(() => Deferred.succeed(secondInterrupted, undefined)), - ), - }) - - yield* coordinator.wake("first") - yield* coordinator.wake("second") - yield* Effect.all([Deferred.await(firstStarted), Deferred.await(secondStarted)]) - - yield* coordinator.interrupt("first") - expect(yield* Deferred.isDone(secondInterrupted)).toBeFalse() - yield* Deferred.succeed(secondGate, undefined) - yield* coordinator.awaitIdle("second") - }), - ), - ) - - it.effect("interrupts the active drain and suppresses its queued wake", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const interrupted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), - ) - : Effect.void, - ), + Deferred.succeed(key === "first" ? firstStarted : secondStarted, undefined).pipe( + Effect.andThen(Deferred.await(key === "first" ? firstGate : secondGate)), ), }) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) + expect(Array.from(yield* coordinator.active)).toEqual([]) + const first = yield* coordinator.run("first").pipe(Effect.forkChild) yield* Deferred.await(firstStarted) - yield* coordinator.wake("session") + expect(Array.from(yield* coordinator.active)).toEqual(["first"]) - yield* coordinator.interrupt("session") - yield* Deferred.await(interrupted) - yield* coordinator.awaitIdle("session") - const exit = yield* Fiber.await(run) - expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() - expect(runs).toBe(1) - yield* coordinator.interrupt("session") - }), - ), - ) - - it.effect("suppresses a wake received during interruption cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* coordinator.wake("session", 1) - yield* Deferred.await(firstInterrupted) - expect(runs).toBe(1) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(interrupt) - yield* coordinator.awaitIdle("session") - - expect(runs).toBe(1) - yield* coordinator.wake("session", 3) + const second = yield* coordinator.run("second").pipe(Effect.forkChild) yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(2) - }), - ), - ) - - it.effect("remembers a wake received after the interrupt boundary during cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild) - yield* Deferred.await(firstInterrupted) - yield* coordinator.wake("session", 3) - const staleInterrupt = yield* coordinator.interrupt("session", 1).pipe(Effect.forkChild) - expect(runs).toBe(1) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(interrupt) - yield* Fiber.join(staleInterrupt) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") - - expect(runs).toBe(2) - }), - ), - ) - - it.effect("moves the stop barrier forward for repeated interrupts", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const firstInterrupted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const firstInterrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild) - yield* Deferred.await(firstInterrupted) - yield* coordinator.wake("session", 3) - const secondInterrupt = yield* coordinator.interrupt("session", 4).pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(firstInterrupt) - yield* Fiber.join(secondInterrupt) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(1) - - yield* coordinator.wake("session", 5) - yield* Deferred.await(secondStarted) - yield* coordinator.awaitIdle("session") - expect(runs).toBe(2) - }), - ), - ) - - it.effect("interrupts an explicit run queued before the interruption request", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void, - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Effect.yieldNow - - yield* coordinator.interrupt("session") - const exit = yield* Fiber.await(run) - expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() - expect(runs).toBe(1) - }), - ), - ) - - it.effect("settles a pre-interrupt explicit run only after active wake cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const runSettled = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - const run = yield* coordinator - .run("session") - .pipe(Effect.exit, Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild) - const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - - expect(yield* Deferred.isDone(runSettled)).toBeFalse() - yield* Deferred.succeed(cleanupGate, undefined) - const runExit = yield* Fiber.join(run) - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - yield* Fiber.join(interrupt) - }), - ), - ) - - it.effect("starts an explicit run arriving during interrupt cleanup after the stop barrier", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - yield* Fiber.join(interrupt) - yield* Fiber.join(run) - yield* Deferred.await(secondStarted) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("interrupts pre-stop waiters and runs post-stop waiters after cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - : Deferred.succeed(secondStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const before = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild) - const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const after = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - - const beforeExit = yield* Fiber.join(before) - expect(Exit.isFailure(beforeExit) && Cause.hasInterruptsOnly(beforeExit.cause)).toBeTrue() - yield* Fiber.join(interrupt) - yield* Fiber.join(after) - yield* Deferred.await(secondStarted) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("waits for interrupt cleanup before settling callers", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const runSettled = yield* Deferred.make() - const idleSettled = yield* Deferred.make() - const interruptSettled = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ), - }) - - const run = yield* coordinator - .run("session") - .pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild) - yield* Deferred.await(started) - const idle = yield* coordinator - .awaitIdle("session") - .pipe(Effect.exit, Effect.ensuring(Deferred.succeed(idleSettled, undefined)), Effect.forkChild) - const interrupt = yield* coordinator - .interrupt("session") - .pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild) - yield* Deferred.await(cleanupStarted) - - expect(yield* Deferred.isDone(runSettled)).toBeFalse() - expect(yield* Deferred.isDone(idleSettled)).toBeFalse() - expect(yield* Deferred.isDone(interruptSettled)).toBeFalse() - yield* Deferred.succeed(cleanupGate, undefined) - const runExit = yield* Fiber.await(run) - const idleExit = yield* Fiber.join(idle) - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - expect(Exit.isFailure(idleExit) && Cause.hasInterruptsOnly(idleExit.cause)).toBeTrue() - yield* Fiber.join(interrupt) - }), - ), - ) - - it.effect("joins concurrent interruption requests for one active drain", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - const first = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const second = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) + expect(Array.from(yield* coordinator.active)).toEqual(["first", "second"]) + yield* Deferred.succeed(firstGate, undefined) yield* Fiber.join(first) + expect(Array.from(yield* coordinator.active)).toEqual(["second"]) + yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(second) + expect(Array.from(yield* coordinator.active)).toEqual([]) }), ), ) - it.effect("does not discard a post-stop explicit run when interrupted again", () => + it.effect("cleans active executions after failure and defect", () => + Effect.scoped( + Effect.gen(function* () { + const failure = new Error("failed") + const defect = new Error("defect") + const coordinator = yield* SessionRunCoordinator.make({ + drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)), + }) + + const failed = yield* coordinator.run("failure").pipe(Effect.exit) + expect(Exit.isFailure(failed) && Cause.hasFails(failed.cause)).toBeTrue() + expect(Array.from(yield* coordinator.active)).toEqual([]) + + const died = yield* coordinator.run("defect").pipe(Effect.exit) + expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue() + expect(Array.from(yield* coordinator.active)).toEqual([]) + }), + ), + ) + + it.effect("cleans active executions when its scope closes", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + const coordinator = yield* Effect.scoped( + Effect.gen(function* () { + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + }) + yield* coordinator.wake("session") + yield* Deferred.await(started) + expect(Array.from(yield* coordinator.active)).toEqual(["session"]) + return coordinator + }), + ) + + expect(Array.from(yield* coordinator.active)).toEqual([]) + }), + ) + + it.effect("coalesces wakes received during active execution", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() + const firstGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => ++runs).pipe( Effect.flatMap((run) => run === 1 - ? Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) + ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(firstGate))) : Deferred.succeed(secondStarted, undefined), ), ), }) - yield* coordinator.wake("session") + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Deferred.await(firstStarted) - const firstInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - const secondInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) - yield* Deferred.succeed(cleanupGate, undefined) - - yield* Effect.all([Fiber.join(firstInterrupt), Fiber.join(secondInterrupt), Fiber.join(run)]) - yield* Deferred.await(secondStarted) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("coalesces wakes received during an active run", () => - Effect.scoped( - Effect.gen(function* () { - const gate = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe(Effect.flatMap((run) => (run === 1 ? Deferred.await(gate) : Effect.void))), - }) - - const first = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Effect.yieldNow yield* Effect.all([coordinator.wake("session"), coordinator.wake("session"), coordinator.wake("session")], { concurrency: "unbounded", }) - yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - - expect(runs).toBe(2) - }), - ), - ) - - it.effect("waits for a coalesced ownership chain to become idle", () => - Effect.scoped( - Effect.gen(function* () { - const firstGate = yield* Deferred.make() - const secondGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const idleSettled = yield* Deferred.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.await(firstGate) - : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))), - ), - ), - }) - - yield* coordinator.wake("session") - const idle = yield* coordinator - .awaitIdle("session") - .pipe(Effect.andThen(Deferred.succeed(idleSettled, undefined)), Effect.forkChild) - yield* coordinator.wake("session") yield* Deferred.succeed(firstGate, undefined) yield* Deferred.await(secondStarted) - expect(yield* Deferred.isDone(idleSettled)).toBeFalse() - yield* Deferred.succeed(secondGate, undefined) - yield* Fiber.join(idle) + yield* Fiber.join(resumed) expect(runs).toBe(2) }), ), ) - it.effect("reports the first defect after a failed chain becomes idle", () => - Effect.scoped( - Effect.gen(function* () { - const firstGate = yield* Deferred.make() - const secondGate = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const defect = new Error("defect") - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => - Effect.sync(() => ++runs).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.await(firstGate).pipe(Effect.andThen(Effect.die(defect))) - : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))), - ), - ), - }) - - yield* coordinator.wake("session") - const idle = yield* coordinator - .awaitIdle("session") - .pipe(Effect.catchDefect(Effect.succeed), Effect.forkChild({ startImmediately: true })) - yield* coordinator.wake("session") - yield* Deferred.succeed(firstGate, undefined) - yield* Deferred.await(secondStarted) - yield* Deferred.succeed(secondGate, undefined) - - expect(yield* Fiber.join(idle)).toBe(defect) - expect(runs).toBe(2) - }), - ), - ) - - it.effect("runs again when woken during the coalesced drain", () => + it.effect("runs again when woken during the follow-up", () => Effect.scoped( Effect.gen(function* () { const firstGate = yield* Deferred.make() const secondStarted = yield* Deferred.make() const secondGate = yield* Deferred.make() + const thirdStarted = yield* Deferred.make() let runs = 0 const coordinator = yield* SessionRunCoordinator.make({ drain: () => @@ -676,196 +186,169 @@ describe("SessionRunCoordinator", () => { ? Deferred.await(firstGate) : run === 2 ? Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))) - : Effect.void, + : Deferred.succeed(thirdStarted, undefined), ), ), }) - const first = yield* coordinator.run("session").pipe(Effect.forkChild) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Effect.yieldNow yield* coordinator.wake("session") yield* Deferred.succeed(firstGate, undefined) yield* Deferred.await(secondStarted) yield* coordinator.wake("session") yield* Deferred.succeed(secondGate, undefined) - yield* Fiber.join(first) + yield* Deferred.await(thirdStarted) + yield* Fiber.join(resumed) expect(runs).toBe(3) }), ), ) - it.effect("starts one successor after a wake races with failure", () => + it.effect("does nothing when interrupted while idle", () => + Effect.scoped( + Effect.gen(function* () { + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) + yield* coordinator.interrupt("session") + }), + ), + ) + + it.effect("interrupts active execution and clears its pending wake", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make() + const interrupted = yield* Deferred.make() + let runs = 0 + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => + Effect.sync(() => ++runs).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ), + }) + + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* coordinator.wake("session") + yield* coordinator.interrupt("session") + yield* Deferred.await(interrupted) + + const exit = yield* Fiber.await(resumed) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() + expect(Array.from(yield* coordinator.active)).toEqual([]) + expect(runs).toBe(1) + }), + ), + ) + + it.effect("runs a wake registered during interruption cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const firstStarted = yield* Deferred.make() + const cleanupStarted = yield* Deferred.make() + const cleanupGate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + let runs = 0 + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => + Effect.sync(() => ++runs).pipe( + Effect.flatMap((run) => + run === 1 + ? Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), + ), + ) + : Deferred.succeed(secondStarted, undefined), + ), + ), + }) + + yield* coordinator.wake("session") + yield* Deferred.await(firstStarted) + const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) + yield* Deferred.await(cleanupStarted) + yield* coordinator.wake("session") + yield* Deferred.succeed(cleanupGate, undefined) + yield* Fiber.join(interrupt) + yield* Deferred.await(secondStarted) + + expect(runs).toBe(2) + }), + ), + ) + + it.effect("starts a resume registered during interruption cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const firstStarted = yield* Deferred.make() + const cleanupStarted = yield* Deferred.make() + const cleanupGate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const forces: boolean[] = [] + const coordinator = yield* SessionRunCoordinator.make({ + drain: (_key, force) => { + forces.push(force) + return forces.length === 1 + ? Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), + ), + ) + : Deferred.succeed(secondStarted, undefined) + }, + }) + + yield* coordinator.wake("session") + yield* Deferred.await(firstStarted) + const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) + yield* Deferred.await(cleanupStarted) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) + yield* Deferred.succeed(cleanupGate, undefined) + yield* Effect.all([Fiber.join(interrupt), Fiber.join(resumed)]) + yield* Deferred.await(secondStarted) + + expect(forces).toEqual([false, true]) + }), + ), + ) + + it.effect("starts one follow-up when a wake races with failure", () => Effect.scoped( Effect.gen(function* () { const gate = yield* Deferred.make() + const secondStarted = yield* Deferred.make() const failure = new Error("failed") let runs = 0 const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => ++runs).pipe( Effect.flatMap((run) => - run === 1 ? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure))) : Effect.void, + run === 1 + ? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure))) + : Deferred.succeed(secondStarted, undefined), ), ), }) - const first = yield* coordinator.run("session").pipe(Effect.forkChild) + const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) yield* Effect.yieldNow yield* coordinator.wake("session") yield* Deferred.succeed(gate, undefined) - expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure) - yield* Effect.yieldNow + expect(yield* Fiber.join(resumed).pipe(Effect.flip)).toBe(failure) + yield* Deferred.await(secondStarted) expect(runs).toBe(2) }), ), ) - it.effect("upgrades an active wake when an explicit run joins it", () => - Effect.scoped( - Effect.gen(function* () { - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, mode) => - Effect.sync(() => modes.push(mode)).pipe( - Effect.andThen( - mode === "wake" - ? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate))) - : Effect.void, - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(wakeStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(wakeGate, undefined) - yield* Fiber.join(run) - - expect(modes).toEqual(["wake", "run"]) - }), - ), - ) - - it.effect("upgrades a recursive wake drain when an explicit run joins it", () => - Effect.scoped( - Effect.gen(function* () { - const runGate = yield* Deferred.make() - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const forcedStarted = yield* Deferred.make() - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, mode) => - Effect.gen(function* () { - modes.push(mode) - if (modes.length === 1) return yield* Deferred.await(runGate) - if (modes.length === 2) - return yield* Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate))) - yield* Deferred.succeed(forcedStarted, undefined) - }), - }) - - const first = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* coordinator.wake("session") - yield* Deferred.succeed(runGate, undefined) - yield* Deferred.await(wakeStarted) - const second = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(wakeGate, undefined) - yield* Deferred.await(forcedStarted) - yield* Fiber.join(first) - yield* Fiber.join(second) - - expect(modes).toEqual(["run", "wake", "run"]) - }), - ), - ) - - it.effect("propagates an upgraded explicit run failure before a successful advisory successor", () => - Effect.scoped( - Effect.gen(function* () { - const wakeStarted = yield* Deferred.make() - const wakeGate = yield* Deferred.make() - const runStarted = yield* Deferred.make() - const runGate = yield* Deferred.make() - const advisoryStarted = yield* Deferred.make() - const failure = new Error("explicit run failed") - const modes: SessionRunCoordinator.Mode[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, mode) => - Effect.sync(() => modes.push(mode)).pipe( - Effect.flatMap((run) => - run === 1 - ? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate))) - : run === 2 - ? Deferred.succeed(runStarted, undefined).pipe( - Effect.andThen(Deferred.await(runGate)), - Effect.andThen(Effect.fail(failure)), - ) - : Deferred.succeed(advisoryStarted, undefined), - ), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(wakeStarted) - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.succeed(wakeGate, undefined) - yield* Deferred.await(runStarted) - yield* coordinator.wake("session") - yield* Deferred.succeed(runGate, undefined) - yield* Deferred.await(advisoryStarted) - - expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) - expect(modes).toEqual(["wake", "run", "wake"]) - }), - ), - ) - - it.effect("settles active callers when its owning scope closes", () => - Effect.gen(function* () { - const scope = yield* Scope.make() - const started = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - }).pipe(Scope.provide(scope)) - - const run = yield* coordinator.run("session").pipe(Effect.forkChild) - yield* Deferred.await(started) - const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* Scope.close(scope, Exit.void) - - const runExit = yield* Fiber.await(run) - const idleExit = yield* Fiber.await(idle) - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - expect(Exit.isSuccess(idleExit)).toBeTrue() - }), - ) - - it.effect("does not start work after its owning scope closes", () => - Effect.gen(function* () { - const scope = yield* Scope.make() - let runs = 0 - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.sync(() => runs++), - }).pipe(Scope.provide(scope)) - yield* Scope.close(scope, Exit.void) - - yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session") - const runExit = yield* coordinator.run("session").pipe(Effect.exit) - - expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue() - expect(runs).toBe(0) - }), - ) - - it.effect("does not cancel the owner when one joined waiter is interrupted", () => + it.effect("does not cancel execution when a joined waiter is interrupted", () => Effect.scoped( Effect.gen(function* () { const gate = yield* Deferred.make() @@ -904,105 +387,29 @@ describe("SessionRunCoordinator", () => { const second = yield* coordinator.run("second").pipe(Effect.forkChild) yield* Deferred.await(bothStarted) yield* Deferred.succeed(gate, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) }), ), ) - it.effect("reports an advisory drain failure exactly once", () => - Effect.scoped( - Effect.gen(function* () { - const failure = new Error("wake failed") - const reported: Cause.Cause[] = [] - const reportedOnce = yield* Deferred.make() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.fail(failure), - onFailure: (_key, cause) => - Effect.sync(() => reported.push(cause)).pipe(Effect.andThen(Deferred.succeed(reportedOnce, undefined))), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(reportedOnce) - yield* Effect.yieldNow - - expect(reported).toHaveLength(1) - expect(Cause.squash(reported[0]!)).toBe(failure) - }), - ), - ) - - it.effect("contains defects thrown while constructing an advisory failure report", () => - Effect.scoped( - Effect.gen(function* () { - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.fail(new Error("wake failed")), - onFailure: () => { - throw new Error("report defect") - }, - }) - - yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session").pipe(Effect.exit) - yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session").pipe(Effect.exit) - }), - ), - ) - - it.effect("reports an independently interrupted advisory drain", () => - Effect.scoped( - Effect.gen(function* () { - const reported = yield* Deferred.make>() - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Effect.interrupt, - onFailure: (_key, cause) => Deferred.succeed(reported, cause).pipe(Effect.asVoid), - }) - - yield* coordinator.wake("session") - - expect(Cause.hasInterruptsOnly(yield* Deferred.await(reported))).toBeTrue() - }), - ), - ) - - it.effect("does not report deliberate interruption as an advisory failure", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const reported: Cause.Cause[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - onFailure: (_key, cause) => Effect.sync(() => reported.push(cause)), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - yield* coordinator.interrupt("session") - yield* Effect.yieldNow - - expect(reported).toEqual([]) - }), - ), - ) - - it.effect("trampolines many synchronous self-waking drains", () => + it.effect("trampolines synchronous self-waking execution", () => Effect.scoped( Effect.gen(function* () { const limit = 20_000 + const completed = yield* Deferred.make() let runs = 0 let wake: (key: string) => Effect.Effect = () => Effect.void - const coordinator = yield* SessionRunCoordinator.make({ + const coordinator = yield* SessionRunCoordinator.make({ drain: (key) => Effect.sync(() => ++runs).pipe( - Effect.tap((run) => (run < limit ? wake(key) : Effect.void)), + Effect.tap((run) => (run < limit ? wake(key) : Deferred.succeed(completed, undefined))), Effect.asVoid, ), }) wake = coordinator.wake yield* coordinator.wake("session") - yield* coordinator.awaitIdle("session") + yield* Deferred.await(completed) expect(runs).toBe(limit) }), diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 708fd9e7f8e..5798b665a86 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -14,44 +14,77 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { - test("maps every top-level V2 Session message type", () => { - const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) + test("omits empty assistant turns", () => { + const assistant = (value: string, content: SessionMessage.Assistant["content"]) => + SessionMessage.Assistant.make({ + id: id(value), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content, + time: { created, completed: created }, + }) const messages = toLLMMessages( [ - new SessionMessage.AgentSwitched({ + assistant("empty", []), + assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]), + assistant("empty-reasoning", [ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }), + ]), + assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]), + assistant("reasoning", [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning", + text: "", + providerMetadata: { anthropic: { signature: "sig_1" } }, + }), + ]), + ], + model, + ) + + expect(messages.map((message) => message.id)).toEqual([id("text"), id("reasoning")]) + }) + + test("maps every top-level V2 Session message type", () => { + const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) + const messages = toLLMMessages( + [ + SessionMessage.AgentSwitched.make({ id: id("agent"), type: "agent-switched", agent: "build", time: { created }, }), - new SessionMessage.ModelSwitched({ + SessionMessage.ModelSwitched.make({ id: id("model"), type: "model-switched", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, time: { created }, }), - new SessionMessage.System({ + SessionMessage.System.make({ id: id("system"), type: "system", text: "Updated context\n\nOther context", time: { created }, }), - new SessionMessage.User({ + SessionMessage.User.make({ id: id("user"), type: "user", text: "Inspect this image", files: [file], - agents: [new AgentAttachment({ name: "build" })], + agents: [AgentAttachment.make({ name: "build" })], time: { created }, }), - new SessionMessage.Synthetic({ + SessionMessage.Synthetic.make({ id: id("synthetic"), type: "synthetic", sessionID: SessionV2.ID.make("ses_translate"), text: "Synthetic context", time: { created }, }), - new SessionMessage.Shell({ + SessionMessage.Shell.make({ id: id("shell"), type: "shell", callID: "shell-1", @@ -59,7 +92,7 @@ describe("toLLMMessages", () => { output: "/project", time: { created, completed: created }, }), - new SessionMessage.Compaction({ + SessionMessage.Compaction.make({ id: id("compaction"), type: "compaction", reason: "auto", @@ -109,31 +142,31 @@ Recent work test("replays durable tool media into canonical tool messages without structured base64", () => { const messages = toLLMMessages( [ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id("assistant"), type: "assistant", agent: "build", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ - new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }), - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }), + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning-1", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "pending", name: "read", - state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }), + state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }), time: { created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "running", name: "read", - state: new SessionMessage.ToolStateRunning({ + state: SessionMessage.ToolStateRunning.make({ status: "running", input: { path: "README.md" }, content: [], @@ -141,11 +174,11 @@ Recent work }), time: { created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "completed", name: "read", - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { path: "README.md" }, content: [ @@ -161,7 +194,7 @@ Recent work }), time: { created, completed: created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "hosted", name: "web_search", @@ -170,7 +203,7 @@ Recent work metadata: { fake: { continuation: "hosted-call" } }, resultMetadata: { fake: { continuation: "hosted-result" } }, }, - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, content: [{ type: "text", text: "Found it" }], @@ -178,12 +211,12 @@ Recent work }), time: { created, completed: created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-failed", name: "write", provider: { executed: true, metadata: { fake: { continuation: "failed" } } }, - state: new SessionMessage.ToolStateError({ + state: SessionMessage.ToolStateError.make({ status: "error", input: { path: "README.md" }, content: [], @@ -266,13 +299,13 @@ Recent work test("restores OpenAI encrypted reasoning metadata", () => { const messages = toLLMMessages( [ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id("assistant-openai-reasoning"), type: "assistant", agent: "build", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning-openai", text: "Think", @@ -294,22 +327,94 @@ Recent work ]) }) + test("drops provider-native continuation metadata from failed assistant turns", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-failed"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning-failed", + text: "Partial thought", + providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "hosted-failed", + name: "web_search", + provider: { + executed: true, + metadata: { openai: { itemId: "call_failed" } }, + resultMetadata: { openai: { itemId: "result_failed" } }, + }, + state: SessionMessage.ToolStateError.make({ + status: "error", + input: { query: "Effect" }, + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }), + time: { created, completed: created }, + }), + ], + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, + time: { created, completed: created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "reasoning", text: "Partial thought", providerMetadata: undefined }, + { + type: "tool-call", + id: "hosted-failed", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: undefined, + }, + { + type: "tool-result", + id: "hosted-failed", + name: "web_search", + result: { + type: "error", + value: { + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }, + }, + providerExecuted: true, + cache: undefined, + metadata: undefined, + providerMetadata: undefined, + }, + ]) + }) + test("drops provider-native continuation metadata after a model switch", () => { const messages = toLLMMessages( [ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id("assistant-old-model"), type: "assistant", agent: "build", model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") }, content: [ - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning-old-model", text: "Visible thought", providerMetadata: { anthropic: { signature: "sig_old" } }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-old-model", name: "web_search", @@ -318,7 +423,7 @@ Recent work metadata: { openai: { itemId: "hosted-old-model" } }, resultMetadata: { openai: { itemId: "hosted-old-model" } }, }, - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, content: [], @@ -327,7 +432,7 @@ Recent work }), time: { created, completed: created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "local-old-model", name: "read", @@ -336,7 +441,7 @@ Recent work metadata: { fake: { call: "old" } }, resultMetadata: { fake: { result: "old" } }, }, - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { path: "README.md" }, content: [], diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index e1f5e32b752..49bbce95a38 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { LLM } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" -import { ConfigProvider, DateTime, Effect } from "effect" +import { DateTime, Effect } from "effect" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" @@ -23,7 +23,7 @@ type Api = | { readonly type: "native"; readonly url?: string; readonly settings: Record } const model = (api: Api, variants: ModelV2.Info["variants"] = []) => - new ModelV2.Info({ + ModelV2.Info.make({ id: ModelV2.ID.make("test-model"), providerID: ProviderV2.ID.make("test-provider"), name: "Test model", @@ -32,25 +32,15 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => request: { headers: { "x-test": "header" }, body: { apiKey: "secret", custom_extension: { enabled: true } }, - generation: { temperature: 0.7 }, - options: { store: false, serviceTier: "priority" }, }, variants, - time: { released: DateTime.makeUnsafe(0) }, + time: { released: 0 }, cost: [], status: "active", enabled: true, limit: { context: 100, output: 20 }, }) -const provider = (api: ProviderV2.Info["api"]) => - new ProviderV2.Info({ - id: ProviderV2.ID.make("test-provider"), - name: "Test provider", - api, - request: { headers: {}, body: {} }, - }) - describe("SessionRunnerModel", () => { it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () => Effect.gen(function* () { @@ -65,8 +55,6 @@ describe("SessionRunnerModel", () => { defaults: { headers: { "x-test": "header" }, limits: { context: 100, output: 20 }, - generation: { temperature: 0.7 }, - providerOptions: { openai: { store: false, serviceTier: "priority" } }, http: { body: { custom_extension: { enabled: true } } }, }, }) @@ -88,14 +76,14 @@ describe("SessionRunnerModel", () => { it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - new ModelV2.Info({ + ModelV2.Info.make({ ...model({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1", settings: { apiKey: "settings-secret", compatibility: "strict" }, }), - request: { headers: {}, body: {}, generation: {}, options: {} }, + request: { headers: {}, body: {} }, }), ) const request = LLM.request({ model: resolved, prompt: "Hello" }) @@ -112,21 +100,20 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("lowers selected OpenAI Session variants into Responses options", () => + it.effect("overlays selected OpenAI Session variant bodies", () => Effect.gen(function* () { - const base = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [ + const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [ { id: ModelV2.VariantID.make("high"), headers: { "x-variant": "high" }, - body: {}, - generation: { temperature: 0.2 }, - options: { reasoningEffort: "high" }, + body: { + store: false, + service_tier: "priority", + temperature: 0.2, + reasoning: { effort: "high" }, + }, }, ]) - const catalog = new ModelV2.Info({ - ...base, - request: { ...base.request, options: { ...base.request.options, reasoningEffort: "medium" } }, - }) const session = SessionV2.Info.make({ id: SessionV2.ID.make("ses_model_variant"), projectID: ProjectV2.ID.global, @@ -143,21 +130,19 @@ describe("SessionRunnerModel", () => { }) const resolved = yield* SessionRunnerModel.resolve(session, catalog) - const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" }) - expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } }) - expect(prepared.body).toMatchObject({ + expect(resolved.route.defaults.http?.body).toEqual({ + custom_extension: { enabled: true }, store: false, service_tier: "priority", temperature: 0.2, reasoning: { effort: "high" }, }) - expect(prepared.body).not.toHaveProperty("reasoningEffort") }), ) - it.effect("lowers selected OpenAI-compatible Session variants into Chat options", () => + it.effect("overlays selected OpenAI-compatible Session variant bodies", () => Effect.gen(function* () { const catalog = model( { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1" }, @@ -165,9 +150,7 @@ describe("SessionRunnerModel", () => { { id: ModelV2.VariantID.make("high"), headers: {}, - body: {}, - generation: {}, - options: { reasoningEffort: "high" }, + body: { store: false, reasoning_effort: "high" }, }, ], ) @@ -183,26 +166,52 @@ describe("SessionRunnerModel", () => { }) const resolved = yield* SessionRunnerModel.resolve(session, catalog) - const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) - expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } }) - expect(prepared.body).toMatchObject({ + expect(resolved.route.defaults.http?.body).toEqual({ + custom_extension: { enabled: true }, store: false, reasoning_effort: "high", }) - expect(prepared.body).not.toHaveProperty("reasoningEffort") }), ) - it.effect("lowers selected Anthropic Session variants into Messages options", () => + it.effect("rejects an explicit unavailable Session variant during model resolution", () => + Effect.gen(function* () { + const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_model_variant_unavailable"), + projectID: ProjectV2.ID.global, + title: "test", + model: { + id: catalog.id, + providerID: catalog.providerID, + variant: ModelV2.VariantID.make("unknown"), + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + + const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.VariantUnavailableError", + providerID: "test-provider", + modelID: "test-model", + variant: "unknown", + }) + expect(failure.message).toBe("Variant unavailable for test-provider/test-model: unknown") + }), + ) + + it.effect("overlays selected Anthropic Session variant bodies", () => Effect.gen(function* () { const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [ { id: ModelV2.VariantID.make("high"), headers: {}, - body: {}, - generation: {}, - options: { thinking: { type: "enabled", budgetTokens: 12000 } }, + body: { thinking: { type: "enabled", budget_tokens: 12000 } }, }, ]) const session = SessionV2.Info.make({ @@ -217,13 +226,11 @@ describe("SessionRunnerModel", () => { }) const resolved = yield* SessionRunnerModel.resolve(session, catalog) - const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) - expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } }) - expect(prepared.body).toMatchObject({ + expect(resolved.route.defaults.http?.body).toEqual({ + custom_extension: { enabled: true }, thinking: { type: "enabled", budget_tokens: 12000 }, }) - expect(JSON.stringify(prepared.body)).not.toContain("budgetTokens") }), ) @@ -240,27 +247,23 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("preserves environment-backed bearer auth", () => + it.effect("uses resolved credentials for bearer auth", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - new ModelV2.Info({ + ModelV2.Info.make({ ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: {}, generation: {}, options: {} }, + request: { headers: {}, body: {} }, }), - { type: "env", name: "TEST_PROVIDER_API_KEY" }, + Credential.Key.make({ type: "key", key: "secret" }), ) const request = LLM.request({ model: resolved, prompt: "Hello" }) - const headers = yield* resolved.route.auth - .apply({ - request, - method: "POST", - url: "https://openai.example/v1/responses", - body: "{}", - headers: Headers.empty, - }) - .pipe( - Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { TEST_PROVIDER_API_KEY: "secret" } }))), - ) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://openai.example/v1/responses", + body: "{}", + headers: Headers.empty, + }) expect(headers.authorization).toBe("Bearer secret") }), @@ -268,18 +271,12 @@ describe("SessionRunnerModel", () => { it.effect("prefers stored credentials over configured auth", () => Effect.gen(function* () { - const credential = new Credential.Stored({ - id: Credential.ID.create(), - integrationID: Integration.ID.make("test-provider"), - label: "Work", - value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }), - }) + const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }) const resolved = yield* SessionRunnerModel.fromCatalogModel( - new ModelV2.Info({ + ModelV2.Info.make({ ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} }, + request: { headers: {}, body: { apiKey: "configured-secret" } }, }), - { type: "credential", id: credential.id, label: credential.label }, credential, ) const headers = yield* resolved.route.auth.apply({ @@ -295,6 +292,27 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("does not project OAuth account metadata into the request body", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: {} }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "secret", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { server: "https://console.example", orgID: "org_123" }, + }), + ) + + expect(resolved.route.defaults.http?.body).toEqual({}) + }), + ) + it.effect("rejects catalog APIs without a native route", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel( @@ -307,6 +325,7 @@ describe("SessionRunnerModel", () => { modelID: "test-model", api: "aisdk:@ai-sdk/google", }) + expect(failure.message).toBe("Unsupported API for test-provider/test-model: aisdk:@ai-sdk/google") }), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index e8da56a3a5c..d45cc8c7341 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -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,13 +15,16 @@ 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 { Snapshot } from "@opencode-ai/core/snapshot" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" +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" @@ -32,10 +38,6 @@ import { Effect, Layer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const cassette = process.env.RECORD === "true" ? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { @@ -58,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" }, @@ -68,65 +68,68 @@ 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(database), - Layer.provide(store), - Layer.provide(events), - 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 coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) +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, - SessionRunCoordinator.Service.pipe( - Effect.map((coordinator) => - SessionExecution.Service.of({ - resume: coordinator.run, - wake: coordinator.wake, - interrupt: coordinator.interrupt, - }), - ), - ), -).pipe(Layer.provide(coordinator)) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - Layer.provide(Project.defaultLayer), - Layer.provide(execution), -) + Effect.gen(function* () { + const sessionRunner = yield* SessionRunner.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + }) + return SessionExecution.Service.of({ + active: coordinator.active, + resume: coordinator.run, + wake: coordinator.wake, + interrupt: coordinator.interrupt, + }) + }), +).pipe(Layer.provide(runnerLayer)) const it = testEffect( - Layer.mergeAll( - database, - events, - projector, - store, - executor, - client, - permission, - agents, - registry, - models, - systemContext, - location, - skillGuidance, - config, - runner, - coordinator, - 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") @@ -157,7 +160,7 @@ describe("SessionRunnerLLM recorded", () => { const session = yield* SessionV2.Service const prompt = yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Say hello in one short sentence." }), + prompt: Prompt.make({ text: "Say hello in one short sentence." }), resume: false, }) @@ -179,7 +182,7 @@ describe("SessionRunnerLLM recorded", () => { .all()).map((event) => event.type), ).toEqual([ "session.next.prompt.admitted.1", - "session.next.prompt.promoted.1", + "session.next.prompted.1", "session.next.step.started.1", "session.next.text.started.1", "session.next.text.ended.1", diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 3d4a858cbbd..f96ea4dea2b 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -19,17 +19,17 @@ const capture = () => { Effect.sync(() => { const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload published.push({ - type: definition.sync ? EventV2.versionedType(definition.type, definition.sync.version) : definition.type, + type: definition.durable + ? EventV2.versionedType(definition.type, definition.durable.version) + : definition.type, data, }) return event }), subscribe: () => Stream.empty, all: () => Stream.empty, - aggregateEvents: () => Stream.empty, - sync: () => Effect.succeed(Effect.void), + durable: () => Stream.empty, listen: () => Effect.succeed(Effect.void), - beforeCommit: () => Effect.void, project: () => Effect.void, replay: () => Effect.void, replayAll: () => Effect.succeed(undefined), @@ -125,3 +125,12 @@ test("old success event data containing result still decodes", () => { }) expect(decoded.result).toMatchObject({ type: "content" }) }) + +test("step finish records settlement without publishing step ended", async () => { + const { published, publisher } = capture() + await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) + await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" }))) + + expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false) + expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" }) +}) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 7c326a9cadf..82cd015aaf6 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -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"), @@ -206,6 +212,7 @@ describe("ToolRegistry", () => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure) + expect(retentionFailure.message).toBe("Failed to write tool output: disk full") }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 39bf8433cd8..d0897ef5f4e 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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,6 +23,7 @@ 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 { Snapshot } from "@opencode-ai/core/snapshot" import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" @@ -26,13 +31,11 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" -import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" 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" @@ -56,11 +59,6 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const requests: LLMRequest[] = [] let response: LLMEvent[] = [] let responses: LLMEvent[][] | undefined @@ -123,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({ @@ -159,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) => @@ -199,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( @@ -234,62 +225,67 @@ const config = Layer.succeed( ]), }), ) -const runner = SessionRunnerLLM.layer.pipe( - Layer.provide(database), - Layer.provide(store), - Layer.provide(events), - 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 coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) +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, - SessionRunCoordinator.Service.pipe( - Effect.map((coordinator) => - SessionExecution.Service.of({ - resume: coordinator.run, - wake: coordinator.wake, - interrupt: coordinator.interrupt, - }), - ), - ), -).pipe(Layer.provide(coordinator)) -const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), - Layer.provide(Project.defaultLayer), - Layer.provide(execution), -) + Effect.gen(function* () { + const sessionRunner = yield* SessionRunner.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), + }) + return SessionExecution.Service.of({ + active: coordinator.active, + resume: coordinator.run, + wake: coordinator.wake, + interrupt: coordinator.interrupt, + }) + }), +).pipe(Layer.provide(runnerLayer)) const it = testEffect( - Layer.mergeAll( - database, - events, - questions, - projector, - store, - client, - permission, - applications, - agents, - registry, - echo, - models, - systemContext, - location, - skillGuidance, - config, - runner, - coordinator, - 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") @@ -355,7 +351,7 @@ const setupOverflowRecovery = Effect.gen(function* () { response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Earlier question ".repeat(700) }), + prompt: Prompt.make({ text: "Earlier question ".repeat(700) }), resume: false, }) yield* session.resume(sessionID) @@ -364,12 +360,12 @@ const setupOverflowRecovery = Effect.gen(function* () { return session }) -const userTexts = (request: LLMRequest) => +const messageTexts = (request: LLMRequest, role: "user" | "system") => request.messages.flatMap((message) => - message.role === "user" - ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) - : [], + message.role === role ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) : [], ) +const userTexts = (request: LLMRequest) => messageTexts(request, "user") +const systemTexts = (request: LLMRequest) => messageTexts(request, "system") const replaySessionProjection = (id: SessionV2.ID) => Effect.gen(function* () { @@ -480,7 +476,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const chunks = Array.from({ length: 32 }, (_, index) => `${index},`) const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks) const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) const events = yield* EventV2.Service const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow @@ -511,7 +507,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => const prompt = `Fail after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) const failure = providerUnavailable() - yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure)) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) @@ -533,7 +529,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => const prompt = `Interrupt after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"]) const streamed = yield* Deferred.make() - yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat( Stream.fromIterable(fixture.partialEvents), Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), @@ -547,6 +543,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "user", text: prompt }, { type: "assistant", + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } @@ -575,7 +573,7 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use application context" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use application context" }), resume: false }) responses = [ [ LLMEvent.stepStart({ index: 0 }), @@ -623,7 +621,7 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [] - const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) }) + const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) }) expect(requests).toHaveLength(1) expect(yield* session.messages({ sessionID })).toMatchObject([ @@ -636,8 +634,8 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 responses = undefined @@ -664,7 +662,7 @@ describe("SessionRunnerLLM", () => { const { db } = yield* Database.Service const messageID = SessionMessage.ID.create() systemUnavailable = true - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -682,8 +680,7 @@ describe("SessionRunnerLLM", () => { ).toBeUndefined() systemUnavailable = false - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) }) - yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID) + yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) }) expect(requests).toHaveLength(1) expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"]) @@ -696,7 +693,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) @@ -704,7 +701,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Moved, { sessionID, timestamp: DateTime.makeUnsafe(1), - location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), }) expect( yield* db @@ -714,7 +711,7 @@ describe("SessionRunnerLLM", () => { .get(), ).toBeUndefined() - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) const exit = yield* session.resume(sessionID).pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) @@ -728,7 +725,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) response = [] yield* session.resume(sessionID) yield* db @@ -737,7 +734,7 @@ describe("SessionRunnerLLM", () => { .where(eq(SessionContextEpochTable.session_id, sessionID)) .run() .pipe(Effect.orDie) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -748,6 +745,7 @@ describe("SessionRunnerLLM", () => { }), ) + // kilocode_change start - prevent a source-location context epoch after the session moves mid-load it.effect("does not create a source Location epoch after a concurrent Session move", () => Effect.gen(function* () { yield* setup @@ -762,11 +760,11 @@ describe("SessionRunnerLLM", () => { .publish(SessionEvent.Moved, { sessionID, timestamp: DateTime.makeUnsafe(1), - location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true) expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true) @@ -780,18 +778,19 @@ describe("SessionRunnerLLM", () => { expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved")) }), ) + // kilocode_change end it.effect("reuses one durable baseline after the context producer changes", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -819,14 +818,14 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const agent = yield* AgentV2.Service - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.system = "Build agent instructions" agent.mode = "primary" }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-build", ["Done"]).completeEvents @@ -840,7 +839,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const agent = yield* AgentV2.Service - yield* agent.update((editor) => { + yield* agent.transform((editor) => { editor.update(AgentV2.ID.make("build"), (agent) => { agent.system = "Build agent instructions" agent.mode = "primary" @@ -852,7 +851,7 @@ describe("SessionRunnerLLM", () => { editor.default(AgentV2.ID.make("reviewer")) }) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents @@ -868,7 +867,7 @@ describe("SessionRunnerLLM", () => { yield* setup const { db } = yield* Database.Service const agent = yield* AgentV2.Service - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.system = "Reviewer instructions" agent.mode = "primary" @@ -881,7 +880,7 @@ describe("SessionRunnerLLM", () => { .run() .pipe(Effect.orDie) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents @@ -892,13 +891,13 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("composes selected-agent skill guidance and replaces it after an agent switch", () => + it.effect("updates selected-agent skill guidance after an agent switch", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -910,17 +909,18 @@ describe("SessionRunnerLLM", () => { timestamp: DateTime.makeUnsafe(1), agent: "reviewer", }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context\n\nBuild skills"], - ["Initial context\n\nReviewer skills"], + ["Initial context\n\nBuild skills"], ]) + expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills")) }), ) - it.effect("retries first-epoch preparation when the selected agent changes during observation", () => + it.effect("keeps the sampled agent when selection changes during observation", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -940,95 +940,19 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nReviewer skills"], + ["Initial context\n\nBuild skills"], ]) }), ) - it.effect("opens a queued activity once when the selected agent changes during observation", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - let switched = false - systemLoadHook = Effect.suspend(() => { - if (switched) return Effect.void - switched = true - return events - .publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: "reviewer", - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ - sessionID, - prompt: new Prompt({ text: "Queued" }), - delivery: "queue", - resume: false, - }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - - expect(requests).toHaveLength(1) - expect((yield* session.context(sessionID)).filter((message) => message.type === "user")).toHaveLength(1) - }), - ) - - it.effect("retries an agent switch before the final provider-dispatch boundary", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - let switched = false - modelResolveHook = Effect.suspend(() => { - if (switched) return Effect.void - switched = true - return events - .publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: "reviewer", - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nReviewer skills"], - ]) - expect( - yield* db - .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie), - ).toEqual({ replacementSeq: null }) - }), - ) - - it.effect("retries a model switch before the final provider-dispatch boundary", () => + it.effect("keeps the sampled model when selection changes during model resolution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1046,161 +970,27 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) - expect(requests.map((request) => request.model)).toEqual([replacementModel]) + expect(requests.map((request) => request.model)).toEqual([model]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]]) }), ) - it.effect("fences an unchanged epoch read across an agent ABA replacement request", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - let switched = false - systemLoadHook = Effect.suspend(() => { - if (switched) return Effect.void - switched = true - return events - .publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: AgentV2.ID.make("reviewer"), - }) - .pipe( - Effect.andThen( - events.publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(2), - agent: AgentV2.defaultID, - }), - ), - Effect.asVoid, - ) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - - requests.length = 0 - yield* session.resume(sessionID) - - expect(requests).toHaveLength(1) - expect( - yield* db - .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie), - ).toEqual({ replacementSeq: null }) - }), - ) - - it.effect("rejects stale agent guidance when committing an existing-epoch replacement", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: AgentV2.ID.make("reviewer"), - }) - const context = (text: string) => - Effect.succeed( - SystemContext.make({ - key: systemContextKey, - codec: Schema.toCodecJson(Schema.String), - load: Effect.succeed(text), - baseline: String, - update: (_previous, current) => current, - }), - ) - const location = (yield* session.get(sessionID)).location - - expect( - yield* SessionContextEpoch.prepare( - db, - events, - context("Stale build context"), - sessionID, - location, - AgentV2.defaultID, - ).pipe(Effect.catchDefect(Effect.succeed)), - ).toBeInstanceOf(SessionContextEpoch.AgentMismatch) - - expect( - yield* SessionContextEpoch.prepare( - db, - events, - context("Reviewer context"), - sessionID, - location, - AgentV2.ID.make("reviewer"), - ), - ).toMatchObject({ baseline: "Reviewer context" }) - }), - ) - - it.effect("blocks a cross-agent provider turn while replacement context is unavailable", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - skillBaselines.set(AgentV2.defaultID, "Build skills") - skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - agent: AgentV2.ID.make("reviewer"), - }) - systemUnavailable = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - - requests.length = 0 - const blocked = yield* session.resume(sessionID).pipe(Effect.exit) - expect(Exit.isFailure(blocked)).toBe(true) - if (Exit.isFailure(blocked)) - expect(Cause.squash(blocked.cause)).toBeInstanceOf(SessionContextEpoch.AgentReplacementBlocked) - expect(requests).toHaveLength(0) - - systemUnavailable = false - yield* session.resume(sessionID) - expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ - ["Initial context\n\nReviewer skills"], - ]) - }), - ) - it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemRemoved = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) @@ -1211,18 +1001,18 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () => + it.effect("keeps the baseline and chronological System updates after a model switch", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSwitched, { sessionID, @@ -1231,35 +1021,37 @@ describe("SessionRunnerLLM", () => { model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context"], ["Initial context"], - ["Replacement context"], + ["Initial context"], ]) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) - expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"]) + expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2) expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ "user", "user", + "system", "model-switched", "user", + "system", ]) yield* replaySessionProjection(sessionID) - expect(yield* session.messages({ sessionID })).toHaveLength(5) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false }) + expect(yield* session.messages({ sessionID })).toHaveLength(6) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fourth" }), resume: false }) yield* session.resume(sessionID) }), ) - it.effect("defers replacement while admitted context is temporarily unavailable", () => + it.effect("preserves the baseline while context is temporarily unavailable", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1271,124 +1063,27 @@ describe("SessionRunnerLLM", () => { model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) systemUnavailable = false systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context"], ["Initial context"], - ["Replacement context"], + ["Initial context"], ]) }), ) - it.effect("advances a pending replacement to the latest invalidation boundary", () => + it.effect("rebuilds the baseline directly after completed compaction", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") }, - }) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(2), - model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") }, - }) - const latest = yield* SessionInput.latestSeq(db, sessionID) - - expect( - yield* db - .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie), - ).toEqual({ replacementSeq: latest }) - }), - ) - - it.effect("retries epoch preparation until observation-time invalidations settle", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - response = [] - yield* session.resume(sessionID) - - requests.length = 0 - systemBaseline = "Changed context" - let invalidations = 0 - systemLoadHook = Effect.suspend(() => { - if (invalidations === 4) return Effect.void - invalidations++ - return events - .publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(invalidations), - model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") }, - }) - .pipe(Effect.asVoid) - }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - - yield* session.resume(sessionID) - - expect(invalidations).toBe(4) - expect(requests).toHaveLength(1) - expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"]) - }), - ) - - it.effect("replays retained context projections while replacement is pending", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, - }) - - yield* replaySessionProjection(sessionID) - systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) - yield* session.resume(sessionID) - expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context"]) - }), - ) - - it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1409,7 +1104,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1417,7 +1112,7 @@ describe("SessionRunnerLLM", () => { ["Replacement context"], ]) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) }), ) @@ -1429,7 +1124,7 @@ describe("SessionRunnerLLM", () => { response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Earlier question ".repeat(180) }), + prompt: Prompt.make({ text: "Earlier question ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1442,7 +1137,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Recent exact request ".repeat(180) }), + prompt: Prompt.make({ text: "Recent exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1468,7 +1163,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Newest exact request ".repeat(180) }), + prompt: Prompt.make({ text: "Newest exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1496,7 +1191,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents, fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1526,7 +1221,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents, overflow(), ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1554,7 +1249,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents, fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1572,7 +1267,7 @@ describe("SessionRunnerLLM", () => { [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "summary unavailable" })], ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1595,7 +1290,7 @@ describe("SessionRunnerLLM", () => { const firstGate = yield* Deferred.make() const summaryGate = yield* Deferred.make() streamGate = firstGate - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow streamGate = summaryGate @@ -1610,18 +1305,18 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("preserves effective System updates while compaction replacement is blocked", () => + it.effect("preserves effective System updates while compaction rebaseline is blocked", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { @@ -1639,20 +1334,11 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"]) - expect( - requests - .at(-1) - ?.messages.some( - (message) => - message.role === "system" && - message.content[0]?.type === "text" && - message.content[0].text === "Changed context", - ), - ).toBe(true) + expect(systemTexts(requests.at(-1)!)).toContain("Changed context") }), ) @@ -1660,7 +1346,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use tools" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use tools" }), resume: false }) requests.length = 0 responses = undefined @@ -1758,7 +1444,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 authorizations.length = 0 @@ -1817,7 +1503,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 responses = [ @@ -1851,8 +1537,9 @@ describe("SessionRunnerLLM", () => { expect(requests.map((request) => request.model)).toEqual([model, replacementModel]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ ["Initial context"], - ["Replacement context"], + ["Initial context"], ]) + expect(systemTexts(requests[1]!)).toContain("Replacement context") }), ) @@ -1860,7 +1547,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Think first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false }) requests.length = 0 response = [ @@ -1898,7 +1585,7 @@ describe("SessionRunnerLLM", () => { }, ]) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1917,7 +1604,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Search first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Search first" }), resume: false }) requests.length = 0 response = [ @@ -1942,7 +1629,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1972,7 +1659,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo five times" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo five times" }), resume: false }) requests.length = 0 executions.length = 0 @@ -2033,7 +1720,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo twice" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false }) requests.length = 0 executions.length = 0 @@ -2121,7 +1808,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run once" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run once" }), resume: false }) requests.length = 0 responses = undefined @@ -2160,7 +1847,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2180,7 +1867,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2199,11 +1886,11 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("starts queued input after the active activity settles", () => + it.effect("promotes queued input after continuation ends", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2231,7 +1918,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait until the next activity" }), + prompt: Prompt.make({ text: "Wait until continuation ends" }), delivery: "queue", }) yield* Deferred.succeed(streamGate, undefined) @@ -2242,7 +1929,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(3) expect(userTexts(requests[0]!)).toEqual(["Start working"]) expect(userTexts(requests[1]!)).toEqual(["Start working"]) - expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until the next activity"]) + expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"]) }), ) @@ -2251,7 +1938,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -2269,7 +1956,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Run after interrupt" }), + prompt: Prompt.make({ text: "Run after interrupt" }), delivery: "queue", }) yield* session.interrupt(sessionID) @@ -2294,7 +1981,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -2312,7 +1999,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Steer after interrupt" }), + prompt: Prompt.make({ text: "Steer after interrupt" }), }) yield* session.interrupt(sessionID) expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) @@ -2332,11 +2019,11 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("runs queued active inputs as separate FIFO activities", () => + it.effect("promotes queued inputs one at a time in FIFO order", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2361,8 +2048,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2375,14 +2062,14 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("opens queued input after idle steering activity settles", () => + it.effect("promotes queued input after steering continuation ends", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering activity" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start steering" }), resume: false }) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Queue later activity" }), + prompt: Prompt.make({ text: "Queue for later" }), delivery: "queue", resume: false, }) @@ -2404,16 +2091,16 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests).toHaveLength(2) - expect(userTexts(requests[0]!)).toEqual(["Start steering activity"]) - expect(userTexts(requests[1]!)).toEqual(["Start steering activity", "Queue later activity"]) + expect(userTexts(requests[0]!)).toEqual(["Start steering"]) + expect(userTexts(requests[1]!)).toEqual(["Start steering", "Queue for later"]) }), ) - it.effect("coalesces steers into the active queued activity before starting the next queued activity", () => + it.effect("promotes steers before the next queued input", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2444,13 +2131,13 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) streamGate = secondGate yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Steer first queued activity" }) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Also steer first queued activity" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Also steer before next queued input" }) }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2461,14 +2148,14 @@ describe("SessionRunnerLLM", () => { expect(userTexts(requests[2]!)).toEqual([ "Start working", "Queue first", - "Steer first queued activity", - "Also steer first queued activity", + "Steer before next queued input", + "Also steer before next queued input", ]) expect(userTexts(requests[3]!)).toEqual([ "Start working", "Queue first", - "Steer first queued activity", - "Also steer first queued activity", + "Steer before next queued input", + "Also steer before next queued input", "Queue second", ]) }), @@ -2478,7 +2165,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2498,8 +2185,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First steer" }) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second steer" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First steer" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second steer" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2508,7 +2195,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"]) - yield* (yield* SessionRunCoordinator.Service).wake(sessionID) + yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow expect(requests).toHaveLength(2) }), @@ -2518,7 +2205,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = undefined @@ -2529,7 +2216,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover with this" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover with this" }) }) yield* Deferred.succeed(streamGate, undefined) expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure) @@ -2548,7 +2235,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { @@ -2610,7 +2297,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Recover interrupted hosted tool" }), + prompt: Prompt.make({ text: "Recover interrupted hosted tool" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) @@ -2670,7 +2357,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Recover interrupted tool input" }), + prompt: Prompt.make({ text: "Recover interrupted tool input" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) @@ -2702,46 +2389,23 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("starts the first queued activity when woken while idle", () => + it.effect("promotes the first queued input when woken while idle", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait for fresh activity" }), + prompt: Prompt.make({ text: "Wait in queue" }), delivery: "queue", resume: false, }) requests.length = 0 - yield* (yield* SessionRunCoordinator.Service).wake(sessionID) + yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow expect(requests).toHaveLength(1) - expect(userTexts(requests[0]!)).toEqual(["Wait for fresh activity"]) - }), - ) - - it.effect("does not spend one activity step budget across queued activities", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const queued = Array.from({ length: 26 }, (_, index) => `Queued activity ${index + 1}`) - for (const text of queued) { - yield* session.prompt({ sessionID, prompt: new Prompt({ text }), delivery: "queue", resume: false }) - } - - requests.length = 0 - responses = queued.map(() => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ]) - - yield* session.resume(sessionID) - - expect(requests).toHaveLength(queued.length) - expect(userTexts(requests.at(-1)!)).toEqual(queued) + expect(userTexts(requests[0]!)).toEqual(["Wait in queue"]) }), ) @@ -2752,8 +2416,8 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void)) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false }) + yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false @@ -2764,7 +2428,7 @@ describe("SessionRunnerLLM", () => { LLMEvent.finish({ reason: "stop" }), ] - yield* (yield* SessionRunCoordinator.Service).wake(sessionID) + yield* (yield* SessionExecution.Service).wake(sessionID) while (requests.length === 0) yield* Effect.yieldNow expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"]) @@ -2777,13 +2441,11 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service yield* events.listen((event) => - event.type === SessionEvent.PromptLifecycle.Promoted.type - ? Effect.die("fail after prompt promotion commits") - : Effect.void, + event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, ) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Run committed promotion" }), + prompt: Prompt.make({ text: "Run committed promotion" }), resume: false, }) @@ -2800,8 +2462,8 @@ describe("SessionRunnerLLM", () => { yield* setup yield* insertSession(otherSessionID) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run first" }), resume: false }) - yield* session.prompt({ sessionID: otherSessionID, prompt: new Prompt({ text: "Run second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run first" }), resume: false }) + yield* session.prompt({ sessionID: otherSessionID, prompt: Prompt.make({ text: "Run second" }), resume: false }) requests.length = 0 responses = undefined @@ -2827,37 +2489,31 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("bounds external session prompt cache keys", () => + it.effect("bounds 64-character session prompt cache keys", () => Effect.gen(function* () { yield* setup - const externalSessionID = SessionV2.ID.fromExternal({ - namespace: "discord", - key: "thread-one", - }) - const otherExternalSessionID = SessionV2.ID.fromExternal({ - namespace: "discord", - key: "thread-two", - }) - yield* insertSession(externalSessionID) - yield* insertSession(otherExternalSessionID) + const longSessionID = SessionV2.ID.make(`ses_${"a".repeat(64)}`) + const otherLongSessionID = SessionV2.ID.make(`ses_${"b".repeat(64)}`) + yield* insertSession(longSessionID) + yield* insertSession(otherLongSessionID) const session = yield* SessionV2.Service yield* session.prompt({ - sessionID: externalSessionID, - prompt: new Prompt({ text: "Run external session" }), + sessionID: longSessionID, + prompt: Prompt.make({ text: "Run long session" }), resume: false, }) yield* session.prompt({ - sessionID: otherExternalSessionID, - prompt: new Prompt({ text: "Run other external session" }), + sessionID: otherLongSessionID, + prompt: Prompt.make({ text: "Run other long session" }), resume: false, }) requests.length = 0 - yield* session.resume(externalSessionID) - yield* session.resume(otherExternalSessionID) + yield* session.resume(longSessionID) + yield* session.resume(otherLongSessionID) const keys = requests.map((request) => request.providerOptions?.openai?.promptCacheKey) - expect(keys).toEqual([externalSessionID.slice(4), otherExternalSessionID.slice(4)]) + expect(keys).toEqual([longSessionID.slice(4), otherLongSessionID.slice(4)]) expect(keys.every((key) => typeof key === "string" && key.length === 64)).toBe(true) expect(keys[0]).not.toBe(keys[1]) }), @@ -2867,7 +2523,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Retry after failure" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry after failure" }), resume: false }) requests.length = 0 responses = undefined @@ -2898,7 +2554,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call missing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call missing" }), resume: false }) requests.length = 0 responses = [ @@ -2940,11 +2596,11 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("propagates unexpected local tool defects operationally", () => + it.effect("returns unexpected local tool defects to the model and continues", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call defect" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call defect" }), resume: false }) requests.length = 0 responses = [ @@ -2954,11 +2610,20 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-after-defect" }), + LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }), + LLMEvent.textEnd({ id: "text-after-defect" }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], ] - expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect") + yield* session.resume(sessionID) - expect(requests).toHaveLength(1) + expect(requests).toHaveLength(2) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Call defect" }, { @@ -2974,6 +2639,7 @@ describe("SessionRunnerLLM", () => { }, ], }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, ]) }), ) @@ -2993,7 +2659,7 @@ describe("SessionRunnerLLM", () => { questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie), }), }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false }) requests.length = 0 responses = [ @@ -3038,7 +2704,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Settle before failing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false }) const failure = providerUnavailable() toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3072,7 +2738,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt blocked tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3118,11 +2784,11 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("interrupts a blocked provider turn without local tool activity", () => + it.effect("interrupts a blocked provider turn without local tool execution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt provider" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt provider" }), resume: false }) requests.length = 0 response = [] streamGate = yield* Deferred.make() @@ -3145,7 +2811,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt tool settlement" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() response = [ @@ -3178,49 +2844,17 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("continues past 25 local tool steps when the agent has no step limit", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false }) - - requests.length = 0 - authorizations.length = 0 - executions.length = 0 - streamGate = undefined - streamStarted = undefined - responses = [ - ...Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]), - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] - - yield* session.resume(sessionID) - - expect(requests).toHaveLength(26) - expect(executions).toHaveLength(25) - }), - ) - it.effect("forces a text response on an agent's configured final step", () => Effect.gen(function* () { yield* setup const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish at the limit" }), resume: false }) requests.length = 0 executions.length = 0 @@ -3258,11 +2892,63 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("resets the configured step allowance when steering input promotes", () => + Effect.gen(function* () { + yield* setup + const agents = yield* AgentV2.Service + yield* agents.transform((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start work" }), resume: false }) + + requests.length = 0 + executions.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-before-steer", name: "echo", input: { text: "before" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-after-steer", name: "echo", input: { text: "after" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + ] + streamGate = yield* Deferred.make() + streamStarted = yield* Deferred.make() + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(streamStarted) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) }) + yield* Deferred.succeed(streamGate, undefined) + yield* Fiber.join(run) + streamGate = undefined + streamStarted = undefined + + expect(requests).toHaveLength(3) + expect(requests[1]?.toolChoice).toBeUndefined() + expect(requests[1]?.tools).not.toEqual([]) + expect(requests[2]?.toolChoice).toMatchObject({ type: "none" }) + expect(executions).toEqual(["before", "after"]) + }), + ) + it.effect("projects provider errors as terminal assistant step failures", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail durably" }), resume: false }) requests.length = 0 responses = undefined @@ -3284,7 +2970,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail before step" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail before step" }), resume: false }) requests.length = 0 response = [LLMEvent.providerError({ message: "Provider unavailable" })] @@ -3303,7 +2989,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail after output" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false }) requests.length = 0 response = [ @@ -3332,7 +3018,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail raw stream durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false }) const failure = providerUnavailable() responseStream = Stream.fail(failure) @@ -3351,7 +3037,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Do not continue failed provider" }), + prompt: Prompt.make({ text: "Do not continue failed provider" }), resume: false, }) @@ -3374,7 +3060,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool durably" }), resume: false }) requests.length = 0 response = [ @@ -3405,7 +3091,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool at EOF" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false }) response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ @@ -3432,7 +3118,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Fail hosted tool on raw failure" }), + prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }), resume: false, }) const failure = providerUnavailable() @@ -3467,7 +3153,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Two blocks" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false }) responses = undefined streamGate = undefined @@ -3530,7 +3216,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call provider tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call provider tool" }), resume: false }) responses = undefined streamGate = undefined diff --git a/packages/core/test/session-todo.test.ts b/packages/core/test/session-todo.test.ts index d1d656af38a..9b848f82709 100644 --- a/packages/core/test/session-todo.test.ts +++ b/packages/core/test/session-todo.test.ts @@ -1,7 +1,9 @@ import { describe, expect } from "bun:test" import { asc } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -11,10 +13,7 @@ import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql" import { SessionTodo } from "@opencode-ai/core/session/todo" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, todos)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionTodo.node]))) const sessionID = SessionV2.ID.make("ses_todo_test") const setup = Effect.gen(function* () { diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 09cc159a20e..6d07b146577 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" import { asc, eq } from "drizzle-orm" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" +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 { ModelV2 } from "@opencode-ai/core/model" @@ -16,10 +17,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) const timestamp = DateTime.makeUnsafe(1) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts new file mode 100644 index 00000000000..959024cd4a9 --- /dev/null +++ b/packages/core/test/shared-schema.test.ts @@ -0,0 +1,206 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { ModelV2 } from "@opencode-ai/core/model" +import { SessionV2 } from "@opencode-ai/core/session" +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Model } from "@opencode-ai/schema/model" +import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt" +import { Provider } from "@opencode-ai/schema/provider" +import { Project } from "@opencode-ai/schema/project" +import { ProjectDirectories } from "@opencode-ai/schema/project-directories" +import { PermissionV1 } from "@opencode-ai/schema/permission-v1" +import { Session } from "@opencode-ai/schema/session" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Workspace } from "@opencode-ai/schema/workspace" +import { Command } from "@opencode-ai/schema/command" +import { Connection } from "@opencode-ai/schema/connection" +import { Credential } from "@opencode-ai/schema/credential" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { Integration } from "@opencode-ai/schema/integration" +import { LLM } from "@opencode-ai/schema/llm" +import { Permission } from "@opencode-ai/schema/permission" +import { Plugin } from "@opencode-ai/schema/plugin" +import { Pty } from "@opencode-ai/schema/pty" +import { Reference } from "@opencode-ai/schema/reference" +import { SessionTodo } from "@opencode-ai/schema/session-todo" +import { Skill } from "@opencode-ai/schema/skill" +import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { PluginV2 } from "@opencode-ai/core/plugin" + +test("Core reuses the canonical shared schemas", async () => { + const [ + coreCommand, + coreConnection, + coreCredential, + coreFileSystem, + coreIntegration, + coreLocation, + coreLLM, + corePermission, + corePermissionV1, + coreProjectCopy, + corePty, + coreProject, + coreReference, + coreSessionInput, + coreSessionMessage, + coreSessionTodo, + corePrompt, + coreSkill, + coreV2Schema, + coreSchema, + coreWorkspace, + ] = await Promise.all([ + import("@opencode-ai/core/command"), + import("@opencode-ai/core/integration/connection"), + import("@opencode-ai/core/credential"), + import("@opencode-ai/core/filesystem"), + import("@opencode-ai/core/integration"), + import("@opencode-ai/core/location"), + import("@opencode-ai/llm"), + import("@opencode-ai/core/permission"), + import("@opencode-ai/core/v1/permission"), + import("@opencode-ai/core/project/copy"), + import("@opencode-ai/core/pty"), + import("@opencode-ai/core/project/schema"), + import("@opencode-ai/core/reference"), + import("@opencode-ai/core/session/input"), + import("@opencode-ai/core/session/message"), + import("@opencode-ai/core/session/todo"), + import("@opencode-ai/core/session/prompt"), + import("@opencode-ai/core/skill"), + import("@opencode-ai/core/v2-schema"), + import("@opencode-ai/core/schema"), + import("@opencode-ai/core/workspace"), + ]) + + const schemas = [ + [AgentV2.ID, Agent.ID], + [AgentV2.Color, Agent.Color], + [AgentV2.Info, Agent.Info], + [coreCommand.Info, Command.Info], + [coreConnection.CredentialInfo, Connection.CredentialInfo], + [coreConnection.EnvInfo, Connection.EnvInfo], + [coreConnection.Info, Connection.Info], + [coreCredential.ID, Credential.ID], + [coreCredential.OAuth, Credential.OAuth], + [coreCredential.Key, Credential.Key], + [coreCredential.Value, Credential.Value], + [coreFileSystem.Entry, FileSystem.Entry], + [coreFileSystem.Submatch, FileSystem.Submatch], + [coreFileSystem.Match, FileSystem.Match], + [coreIntegration.ID, Integration.ID], + [coreIntegration.MethodID, Integration.MethodID], + [coreIntegration.When, Integration.When], + [coreIntegration.TextPrompt, Integration.TextPrompt], + [coreIntegration.SelectPrompt, Integration.SelectPrompt], + [coreIntegration.Prompt, Integration.Prompt], + [coreIntegration.OAuthMethod, Integration.OAuthMethod], + [coreIntegration.KeyMethod, Integration.KeyMethod], + [coreIntegration.EnvMethod, Integration.EnvMethod], + [coreIntegration.Method, Integration.Method], + [coreIntegration.Inputs, Integration.Inputs], + [coreIntegration.Ref, Integration.Ref], + [coreLocation.Ref, Location.Ref], + [coreLLM.ProviderMetadata, LLM.ProviderMetadata], + [coreLLM.ToolTextContent, LLM.ToolTextContent], + [coreLLM.ToolFileContent, LLM.ToolFileContent], + [coreLLM.ToolContent, LLM.ToolContent], + [ModelV2.ID, Model.ID], + [ModelV2.VariantID, Model.VariantID], + [ModelV2.Ref, Model.Ref], + [ModelV2.Family, Model.Family], + [ModelV2.Capabilities, Model.Capabilities], + [ModelV2.Cost, Model.Cost], + [ModelV2.Api, Model.Api], + [ModelV2.Info, Model.Info], + [ProviderV2.ID, Provider.ID], + [ProviderV2.AISDK, Provider.AISDK], + [ProviderV2.Native, Provider.Native], + [ProviderV2.Api, Provider.Api], + [ProviderV2.Request, Provider.Request], + [ProviderV2.Info, Provider.Info], + [corePermission.Effect, Permission.Effect], + [corePermission.Rule, Permission.Rule], + [corePermission.Ruleset, Permission.Ruleset], + [corePermissionV1.Event, PermissionV1.Event], + [coreProjectCopy.Event, ProjectDirectories.Event], + [PluginV2.ID, Plugin.ID], + [PluginV2.Event, Plugin.Event], + [corePty.Info, Pty.Info], + [corePty.Event, Pty.Event], + [coreProject.ID, Project.ID], + [coreReference.LocalSource, Reference.LocalSource], + [coreReference.GitSource, Reference.GitSource], + [coreReference.Source, Reference.Source], + [SessionV2.ID, Session.ID], + [SessionV2.Info, Session.Info], + [SessionV2.ListAnchor, Session.ListAnchor], + [coreSessionInput.Delivery, SessionInput.Delivery], + [coreSessionInput.Admitted, SessionInput.Admitted], + [coreSessionMessage.ID, SessionMessage.ID], + [coreSessionMessage.UnknownError, SessionMessage.UnknownError], + [coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched], + [coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched], + [coreSessionMessage.User, SessionMessage.User], + [coreSessionMessage.Synthetic, SessionMessage.Synthetic], + [coreSessionMessage.System, SessionMessage.System], + [coreSessionMessage.Shell, SessionMessage.Shell], + [coreSessionMessage.ToolStatePending, SessionMessage.ToolStatePending], + [coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning], + [coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted], + [coreSessionMessage.ToolStateError, SessionMessage.ToolStateError], + [coreSessionMessage.ToolState, SessionMessage.ToolState], + [coreSessionMessage.AssistantTool, SessionMessage.AssistantTool], + [coreSessionMessage.AssistantText, SessionMessage.AssistantText], + [coreSessionMessage.AssistantReasoning, SessionMessage.AssistantReasoning], + [coreSessionMessage.AssistantContent, SessionMessage.AssistantContent], + [coreSessionMessage.Assistant, SessionMessage.Assistant], + [coreSessionMessage.Compaction, SessionMessage.Compaction], + [coreSessionMessage.Message, SessionMessage.Message], + [coreSessionTodo.Info, SessionTodo.Info], + [coreSessionTodo.Event, SessionTodo.Event], + [corePrompt.Source, Source], + [corePrompt.FileAttachment, FileAttachment], + [corePrompt.AgentAttachment, AgentAttachment], + [corePrompt.Prompt, Prompt], + [coreSkill.DirectorySource, Skill.DirectorySource], + [coreSkill.UrlSource, Skill.UrlSource], + [coreSkill.EmbeddedSource, Skill.EmbeddedSource], + [coreSkill.Source, Skill.Source], + [coreSkill.Info, Skill.Info], + [coreV2Schema.DateTimeUtcFromMillis, DateTimeUtcFromMillis], + [coreSchema.optional, optional], + [coreSchema.statics, statics], + [coreWorkspace.ID, Workspace.ID], + ] + for (const [core, shared] of schemas) expect(core).toBe(shared) + + expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(AgentV2.Info.empty(AgentV2.ID.make("test"))) + expect(Model.Info.empty(Provider.ID.make("test"), Model.ID.make("model"))).toEqual( + ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")), + ) + expect(Provider.Info.empty(Provider.ID.make("test"))).toEqual(ProviderV2.Info.empty(ProviderV2.ID.make("test"))) + expect(Skill.Source.key(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/tmp") }))).toBe( + "directory:/tmp", + ) +}) + +test("shared record schemas construct and decode plain objects", () => { + const made = Prompt.make({ text: "hello" }) + const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) + const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" }) + + expect(Object.getPrototypeOf(made)).toBe(Object.prototype) + expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype) + expect(Object.getPrototypeOf(content)).toBe(Object.prototype) + expect(Prompt.ast.annotations?.identifier).toBe("Prompt") + expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text") + expect(Prompt.equivalence(Prompt.make({ text: "hello" }), decoded)).toBe(true) + expect(Prompt.fromUserMessage({ text: "hello" })).toEqual(made) + expect(Workspace.ID.ascending("")).toStartWith("wrk_") +}) diff --git a/packages/core/test/skill-discovery.test.ts b/packages/core/test/skill-discovery.test.ts index 5fcecae4c7a..e875beb931b 100644 --- a/packages/core/test/skill-discovery.test.ts +++ b/packages/core/test/skill-discovery.test.ts @@ -3,15 +3,17 @@ import path from "path" import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { FSUtil } from "@opencode-ai/core/fs-util" +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 { Global } from "@opencode-ai/core/global" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { tmpdir } from "./fixture/tmpdir" const base = "https://skills.example.test/catalog/" -async function pull(skills: unknown[], files: Record = {}) { - const tmp = await tmpdir() +async function pull(skills: unknown[], files: Record = {}, cache?: Awaited>) { + const tmp = cache ?? (await tmpdir()) const requests: string[] = [] const http = Layer.succeed( HttpClient.HttpClient, @@ -27,15 +29,14 @@ async function pull(skills: unknown[], files: Record = {}) { ), ), ) - const layer = SkillDiscovery.layer.pipe( - Layer.provide(http), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layerWith({ cache: tmp.path })), - ) + const skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [ + [LayerNodePlatform.httpClient, http], + [Global.node, Global.layerWith({ cache: tmp.path })], + ]) const directories = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* SkillDiscovery.Service).pull(base) - }).pipe(Effect.provide(layer)), + }).pipe(Effect.provide(skillDiscoveryLayer)), ) return { tmp, requests, directories } } @@ -101,4 +102,64 @@ describe("SkillDiscovery.pull", () => { await result.tmp[Symbol.asyncDispose]() } }) + + test("refreshes cached files when the version changes", async () => { + const tmp = await tmpdir() + try { + const first = await pull( + [{ name: "deploy", version: "1", files: ["SKILL.md"] }], + { + [`${base}deploy/SKILL.md`]: "# Old", + }, + tmp, + ) + const second = await pull( + [{ name: "deploy", version: "2", files: ["SKILL.md"] }], + { + [`${base}deploy/SKILL.md`]: "# New", + }, + tmp, + ) + + expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New") + expect(second.requests).toContain(`${base}deploy/SKILL.md`) + const third = await pull( + [{ name: "deploy", version: "2", files: ["SKILL.md"] }], + { [`${base}deploy/SKILL.md`]: "# Ignored" }, + tmp, + ) + expect(third.requests).toEqual([`${base}index.json`]) + } finally { + await tmp[Symbol.asyncDispose]() + } + }) + + test("publishes complete updates and removes stale files", async () => { + const tmp = await tmpdir() + try { + const first = await pull( + [{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }], + { + [`${base}deploy/SKILL.md`]: "# Old", + [`${base}deploy/old.md`]: "old reference", + }, + tmp, + ) + const root = first.directories[0] + + await pull( + [{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }], + { [`${base}deploy/SKILL.md`]: "# Partial" }, + tmp, + ) + expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old") + expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference") + + await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp) + expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New") + expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false) + } finally { + await tmp[Symbol.asyncDispose]() + } + }) }) diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index d0e01d0677e..0f8e1b3cb3d 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -3,6 +3,8 @@ 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 { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" @@ -22,11 +24,7 @@ const discovery = Layer.succeed( }), ) const it = testEffect( - SkillV2.layer.pipe( - Layer.provide(discovery), - Layer.provide(FSUtil.defaultLayer), - Layer.provideMerge(AgentV2.locationLayer), - ), + AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [[SkillDiscovery.node, discovery]]), ) function write(directory: string, name: string, description: string) { @@ -59,8 +57,7 @@ describe("SkillV2", () => { }) const skill = yield* SkillV2.Service - const register = yield* skill.transform() - yield* register((editor) => { + yield* skill.transform((editor) => { editor.source({ type: "directory", path: AbsolutePath.make(first) }) editor.source({ type: "directory", path: AbsolutePath.make(first) }) editor.source({ type: "directory", path: AbsolutePath.make(second) }) @@ -75,7 +72,7 @@ describe("SkillV2", () => { { type: "directory", path: AbsolutePath.make(second) }, ]) expect(yield* skill.list()).toEqual([ - new SkillV2.Info({ + SkillV2.Info.make({ name: "foo", slash: true, location: AbsolutePath.make(path.join(first, "foo.md")), @@ -108,15 +105,14 @@ describe("SkillV2", () => { urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)]) const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" }) }), ) const skill = yield* SkillV2.Service - const register = yield* skill.transform() - yield* register((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) + yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index fce6ea1087e..b059201b309 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -2,7 +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 { PluginBoot } from "@opencode-ai/core/plugin/boot" +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" @@ -10,45 +10,42 @@ import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { it } from "../lib/effect" const build = AgentV2.ID.make("build") -const effect = new SkillV2.Info({ +const effect = SkillV2.Info.make({ name: "effect", description: "Build applications with Effect", location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), content: "Effect guidance", }) -const hidden = new SkillV2.Info({ +const hidden = SkillV2.Info.make({ name: "hidden", location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")), content: "Undescribed guidance", }) -const denied = new SkillV2.Info({ +const denied = SkillV2.Info.make({ name: "denied", description: "Must not be advertised", location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")), content: "Denied guidance", }) -const layer = (list: () => SkillV2.Info[], wait: () => void = () => {}) => - SkillGuidance.layer.pipe( - Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })), - Layer.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.sync(wait) })), - ) +const layer = (list: () => SkillV2.Info[]) => + 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", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [{ action: "skill", resource: "denied", effect: "deny" }], }) let skills = [hidden, denied, effect] - let waited = 0 return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service const initialized = yield* guidance .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap(SystemContext.initialize)) - expect(waited).toBe(1) expect(initialized.baseline).toBe( [ "Skills provide specialized instructions and workflows for specific tasks.", @@ -71,18 +68,11 @@ describe("SkillGuidance", () => { _tag: "Updated", text: expect.stringContaining("No skills are currently available."), }) - }).pipe( - Effect.provide( - layer( - () => skills, - () => waited++, - ), - ), - ) + }).pipe(Effect.provide(layer(() => skills))) }) it.effect("omits guidance when the selected agent denies all skills", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [{ action: "skill", resource: "*", effect: "deny" }], }) @@ -98,7 +88,7 @@ describe("SkillGuidance", () => { }) it.effect("omits guidance when a resource-specific denial follows the global denial", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [ { action: "skill", resource: "*", effect: "deny" }, @@ -117,7 +107,7 @@ describe("SkillGuidance", () => { }) it.effect("retains specifically allowed skills after a global denial", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [ { action: "skill", resource: "*", effect: "deny" }, @@ -133,7 +123,7 @@ describe("SkillGuidance", () => { }) it.effect("omits guidance when a specifically allowed skill is denied again", () => { - const agent = new AgentV2.Info({ + const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [ { action: "skill", resource: "*", effect: "deny" }, diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts new file mode 100644 index 00000000000..5e01fefc17d --- /dev/null +++ b/packages/core/test/snapshot.test.ts @@ -0,0 +1,178 @@ +import { $ } from "bun" +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer } from "effect" +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 { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { Hash } from "@opencode-ai/core/util/hash" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +describe("Snapshot", () => { + testEffect(Layer.empty).live("captures and restores Location-scoped changes", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const location = path.join(project, "scope") + yield* Effect.promise(async () => { + await fs.mkdir(location, { recursive: true }) + await fs.writeFile(path.join(location, "tracked.txt"), "one\n") + await fs.writeFile(path.join(project, "outside.txt"), "outside\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + }) + + const layer = snapshotLayer(tmp.path, location) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.capture() + expect(before).toBeDefined() + if (!before) return + + yield* Effect.promise(async () => { + await fs.writeFile(path.join(location, "tracked.txt"), "two\n") + await fs.writeFile(path.join(location, "added.txt"), "added\n") + await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n") + }) + const after = yield* snapshot.capture() + expect(after).toBeDefined() + if (!after) return + + expect(yield* snapshot.files({ from: before, to: after })).toEqual([ + RelativePath.make("scope/added.txt"), + RelativePath.make("scope/tracked.txt"), + ]) + const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]]) + const preview = yield* snapshot.preview({ files: plan, context: 1 }) + expect(preview).toHaveLength(1) + expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + yield* snapshot.restore({ files: plan }) + expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n") + expect(yield* read(path.join(location, "added.txt"))).toBe("added\n") + expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n") + }).pipe(Effect.provide(layer)) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + testEffect(Layer.empty).live("treats capture outside Git as unavailable", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + expect( + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + return yield* snapshot.capture() + }).pipe(Effect.provide(snapshotLayer(tmp.path, tmp.path))), + ).toBeUndefined() + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const linked = path.join(tmp.path, "linked") + yield* Effect.promise(async () => { + await fs.mkdir(project) + await fs.writeFile(path.join(project, "tracked.txt"), "main\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet() + }) + + const capture = (directory: string) => + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + return yield* snapshot.capture() + }).pipe(Effect.provide(snapshotLayer(tmp.path, directory))) + expect(yield* capture(project)).toBeDefined() + expect(yield* capture(linked)).toBeDefined() + + const projectID = yield* Effect.gen(function* () { + return (yield* Location.Service).project.id + }).pipe( + Effect.provide( + AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + ), + ) + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), + ).toBeDefined() + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))), + ).toBeDefined() + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + yield* Effect.promise(async () => { + await fs.mkdir(project) + await fs.writeFile(path.join(project, "tracked.txt"), "one\n") + await $`git init`.cwd(project).quiet() + await $`git config core.fsmonitor false`.cwd(project).quiet() + await $`git config commit.gpgsign false`.cwd(project).quiet() + await $`git config user.email test@opencode.test`.cwd(project).quiet() + await $`git config user.name Test`.cwd(project).quiet() + await $`git add .`.cwd(project).quiet() + await $`git commit -m initial`.cwd(project).quiet() + }) + + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.capture() + expect(before).toBeDefined() + if (!before) return + yield* Effect.promise(async () => { + await fs.writeFile(path.join(project, "tracked.txt"), "two\n") + await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n") + }) + yield* snapshot.checkout(before) + expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n") + expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n") + }).pipe(Effect.provide(snapshotLayer(tmp.path, project))) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) +}) + +function snapshotLayer(data: string, directory: string) { + 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) { + return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n"))) +} diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index cb795f85682..505cd724e7e 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -13,13 +13,16 @@ describe("State", () => { let block = true const state = State.create({ initial: () => ({ values: [] as string[] }), - editor: (draft) => ({ add: (value: string) => draft.values.push(value) }), + draft: (draft) => ({ add: (value: string) => draft.values.push(value) }), finalize: () => block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void, }) const scope = yield* Scope.make() - const update = yield* state.transform().pipe(Scope.provide(scope)) - const fiber = yield* update((editor) => editor.add("registered")).pipe(Effect.forkChild) + const fiber = yield* state + .transform((editor) => { + editor.add("registered") + }) + .pipe(Scope.provide(scope), Effect.forkChild) yield* Deferred.await(rebuilding) const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild) block = false @@ -31,4 +34,82 @@ describe("State", () => { expect(state.get().values).toEqual([]) }), ) + + it.effect("runs effectful transforms during every reload", () => + Effect.gen(function* () { + let value = "first" + const state = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + }) + + yield* state.transform((editor) => + Effect.sync(() => { + editor.add(value) + }), + ) + expect(state.get().values).toEqual(["first"]) + + value = "second" + yield* state.reload() + expect(state.get().values).toEqual(["second"]) + }), + ) + + it.effect("disposes a transform once and rebuilds remaining state", () => + Effect.gen(function* () { + const state = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + }) + yield* state.transform((editor) => { + editor.add("first") + }) + const registration = yield* state.transform((editor) => { + editor.add("second") + }) + expect(state.get().values).toEqual(["first", "second"]) + + yield* registration.dispose + expect(state.get().values).toEqual(["first"]) + + yield* registration.dispose + expect(state.get().values).toEqual(["first"]) + }), + ) + + it.effect("batches automatic rebuilds", () => + Effect.gen(function* () { + let finalized = 0 + const first = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + finalize: () => Effect.sync(() => finalized++), + }) + const second = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + finalize: () => Effect.sync(() => finalized++), + }) + + yield* State.batch( + Effect.gen(function* () { + yield* first.transform((draft) => { + draft.add("first") + }) + yield* first.transform((draft) => { + draft.add("second") + }) + yield* second.transform((draft) => { + draft.add("third") + }) + expect(finalized).toBe(0) + }), + ) + + expect(first.get().values).toEqual(["first", "second"]) + expect(second.get().values).toEqual(["third"]) + expect(finalized).toBe(2) + }), + ) }) diff --git a/packages/core/test/system-context/builtins.test.ts b/packages/core/test/system-context/builtins.test.ts index a74dd94866a..26b91752236 100644 --- a/packages/core/test/system-context/builtins.test.ts +++ b/packages/core/test/system-context/builtins.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import * as TestClock from "effect/testing/TestClock" +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 { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" @@ -25,12 +27,12 @@ const locationLayer = Layer.succeed( ), ), ) +const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]) const it = testEffect( - SystemContextBuiltIns.locationLayer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layerWith({ config: "/global" })), - Layer.provide(locationLayer), - ), + AppNodeBuilder.build(builtInsNode, [ + [Location.node, locationLayer], + [Global.node, Global.layerWith({ config: "/global" })], + ]), ) const instructionFS = Layer.effect( FSUtil.Service, @@ -43,13 +45,13 @@ const instructionFS = Layer.effect( }), ), ), -).pipe(Layer.provide(FSUtil.defaultLayer)) +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const itWithInstructions = testEffect( - SystemContextBuiltIns.locationLayer.pipe( - Layer.provide(instructionFS), - Layer.provide(Global.layerWith({ config: "/global" })), - Layer.provide(locationLayer), - ), + AppNodeBuilder.build(builtInsNode, [ + [Location.node, locationLayer], + [FSUtil.node, instructionFS], + [Global.node, Global.layerWith({ config: "/global" })], + ]), ) describe("SystemContextBuiltIns", () => { diff --git a/packages/core/test/system-context/registry.test.ts b/packages/core/test/system-context/registry.test.ts index b77b4132a41..3e684930786 100644 --- a/packages/core/test/system-context/registry.test.ts +++ b/packages/core/test/system-context/registry.test.ts @@ -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", () => diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 74eda3378ed..9a14025da73 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -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 = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { 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") => ({ @@ -149,6 +160,29 @@ describe("ApplyPatchTool", () => { { type: "update", resource: "update.txt" }, { type: "delete", resource: "remove.txt" }, ], + files: [ + { + file: "nested/new.txt", + status: "added", + additions: 1, + deletions: 0, + patch: expect.stringContaining("+created"), + }, + { + file: "update.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + { + file: "remove.txt", + status: "deleted", + additions: 0, + deletions: 1, + patch: expect.stringContaining("-remove"), + }, + ], }) expect(assertions).toMatchObject([ { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, @@ -168,6 +202,33 @@ describe("ApplyPatchTool", () => { ), ) + // kilocode_change start + it.live("omits oversized patches from durable structured output", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const content = "x".repeat(ToolOutputStore.MAX_BYTES) + const patch = `*** Begin Patch\n*** Add File: large.txt\n+${content}\n*** End Patch` + return withTool(tmp.path, (registry) => settleTool(registry, call(patch))).pipe( + Effect.tap((settled) => + Effect.sync(() => { + expect(settled.output?.structured).toEqual({ + applied: [{ type: "add", resource: "large.txt", target: path.join(tmp.path, "large.txt") }], + files: [{ file: "large.txt", status: "added", additions: 1, deletions: 0 }], + }) + expect(Buffer.byteLength(JSON.stringify(settled.output?.structured), "utf-8")).toBeLessThanOrEqual( + ToolOutputStore.MAX_BYTES, + ) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + // kilocode_change end + it.live("rejects moves before applying any hunk", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 0fb2cd73551..3a969e9570e 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -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" @@ -31,8 +34,10 @@ let denyAction: string | undefined let result: AppProcess.RunResult = { command: "mock", exitCode: 0, + output: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"), stderr: Buffer.alloc(0), + outputTruncated: false, stdoutTruncated: false, stderrTruncated: false, } @@ -83,8 +88,10 @@ const reset = () => { result = { command: "mock", exitCode: 0, + output: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"), stderr: Buffer.alloc(0), + outputTruncated: false, stdoutTruncated: false, stderrTruncated: false, } @@ -95,24 +102,26 @@ const withTool = ( body: (registry: ToolRegistry.Interface) => Effect.Effect, processLayer: Layer.Layer = 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") => ({ @@ -134,26 +143,34 @@ describe("BashTool", () => { const definitions = yield* toolDefinitions(registry) expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") + expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd") expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) - expect( - yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })), - ).toEqual({ - result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, + expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({ + result: { + type: "content", + value: [ + { type: "text", text: "hello\n" }, + { type: "text", text: "Command exited with code 0." }, + ], + }, output: { structured: { - command: "pwd", - cwd: realpathSync(tmp.path), - exitCode: 0, - output: "hello\n", + exit: 0, truncated: false, }, - content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }], + content: [ + { type: "text", text: "hello\n" }, + { type: "text", text: "Command exited with code 0." }, + ], }, }) expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }]) expect(runs[0]?.options).toMatchObject({ + combineOutput: true, maxOutputBytes: BashTool.MAX_CAPTURE_BYTES, - maxErrorBytes: BashTool.MAX_CAPTURE_BYTES, }) expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }]) }), @@ -219,17 +236,21 @@ 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(() => { - expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." }) - expect(settled.output?.structured).toMatchObject({ - command: "printf core-bash", - cwd: realpathSync(tmp.path), - exitCode: 0, - output: "core-bash", + expect(settled.result).toEqual({ + type: "content", + value: [ + { type: "text", text: "core-bash" }, + { type: "text", text: "Command exited with code 0." }, + ], }) + expect(settled.output?.structured).toMatchObject({ + exit: 0, + }) + expect(settled.output?.structured).not.toHaveProperty("output") }), ), ) @@ -304,11 +325,13 @@ describe("BashTool", () => { expect(assertions.map((item) => item.action)).toEqual(["bash"]) expect(runs).toHaveLength(1) expect(settled.output?.structured).toMatchObject({ - warnings: [ - `Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, - ], + truncated: false, + }) + expect(settled.output?.structured).not.toHaveProperty("warnings") + expect(settled.output?.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("Warnings:"), }) - expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") }) }), ), ) @@ -325,21 +348,19 @@ describe("BashTool", () => { Effect.promise(() => tmpdir()), (tmp) => { reset() - result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") } + result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") } return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toMatchObject({ + expect(settled.output?.content[1]).toMatchObject({ type: "text", - value: expect.stringContaining("Command exited with code 7"), + text: expect.stringContaining("Command exited with code 7"), }) expect(settled.output?.structured).toMatchObject({ - command: "false", - cwd: realpathSync(tmp.path), - exitCode: 7, - output: "HEAD full output TAIL", + exit: 7, truncated: false, }) + expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" }) }), ), ) @@ -353,14 +374,14 @@ describe("BashTool", () => { Effect.promise(() => tmpdir()), (tmp) => { reset() - result = { ...result, stdoutTruncated: true } + result = { ...result, outputTruncated: true } return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true }) - expect(settled.result).toMatchObject({ + expect(settled.output?.structured).toMatchObject({ truncated: true }) + expect(settled.output?.content[0]).toMatchObject({ type: "text", - value: expect.stringContaining("stdout capture truncated"), + text: expect.stringContaining("output capture truncated"), }) expect(settled.output?.structured).not.toHaveProperty("resource") }), @@ -380,13 +401,12 @@ describe("BashTool", () => { return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toMatchObject({ + expect(settled.output?.content[1]).toMatchObject({ type: "text", - value: expect.stringContaining("Command timed out"), + text: expect.stringContaining("Command timed out"), }) expect(settled.output?.structured).toMatchObject({ - command: "sleep 60", - timedOut: true, + timeout: true, truncated: false, }) }), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 57a354fc7c0..f72324a2272 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -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 = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { 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") => ({ @@ -125,11 +136,16 @@ describe("EditTool", () => { value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```", }) expect(settled.output?.structured).toEqual({ - operation: "write", - target: yield* Effect.promise(() => fs.realpath(target)), - resource: "hello.txt", - existed: true, replacements: 1, + files: [ + { + file: "hello.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + ], }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) @@ -143,6 +159,39 @@ describe("EditTool", () => { ), ) + // kilocode_change start + it.live("omits an oversized patch from durable structured output", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const target = path.join(tmp.path, "large.txt") + const before = "a".repeat(ToolOutputStore.MAX_BYTES) + const after = "b".repeat(ToolOutputStore.MAX_BYTES) + return Effect.promise(() => fs.writeFile(target, before)).pipe( + Effect.andThen( + withTool(tmp.path, (registry) => + settleTool(registry, call({ path: "large.txt", oldString: before, newString: after })), + ), + ), + Effect.tap((settled) => + Effect.sync(() => { + expect(settled.output?.structured).toEqual({ + replacements: 1, + files: [{ file: "large.txt", status: "modified", additions: 1, deletions: 1 }], + }) + expect(Buffer.byteLength(JSON.stringify(settled.output?.structured), "utf-8")).toBeLessThanOrEqual( + ToolOutputStore.MAX_BYTES, + ) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + // kilocode_change end + it.live("accepts an absolute file path inside the active Location", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -404,7 +453,7 @@ test("keeps the locked edit schema, semantics docstring, and deferred TODOs visi expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.", diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index f0504e9759d..a2d132a74bb 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import path from "path" import { Cause, Effect, Exit, Fiber, Layer, Option } 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 { Global } from "@opencode-ai/core/global" import { Config } from "@opencode-ai/core/config" @@ -28,14 +30,14 @@ const withStore = ( }), ) : Layer.empty - const store = ToolOutputStore.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(global), - Layer.provide(configured), - ) + + const store = AppNodeBuilder.build(LayerNode.group([ToolOutputStore.node, FSUtil.node]), [ + [Global.node, global], + [Config.node, configured], + ]) return Effect.gen(function* () { return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service }) - }).pipe(Effect.provide(Layer.mergeAll(store, FSUtil.defaultLayer))) + }).pipe(Effect.provide(store)) }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) @@ -185,11 +187,11 @@ describe("ToolOutputStore", () => { writeFileString: () => Effect.never, }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) - const store = ToolOutputStore.layer.pipe( - Layer.provide(blockedFilesystem), - Layer.provide(Global.layerWith({ data: root.path })), - ) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + const store = AppNodeBuilder.build(ToolOutputStore.nodeWithoutConfig, [ + [Global.node, Global.layerWith({ data: root.path })], + [FSUtil.node, blockedFilesystem], + ]) const exit = yield* Effect.gen(function* () { const service = yield* ToolOutputStore.Service const fiber = yield* service diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 98105a8dc07..5c5c95da616 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -1,10 +1,13 @@ import { describe, expect } from "bun:test" import { 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 { PermissionV2 } from "@opencode-ai/core/permission" import { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { QuestionTool } from "@opencode-ai/core/tool/question" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -28,7 +31,6 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const question = Layer.succeed( QuestionV2.Service, QuestionV2.Service.of({ @@ -41,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", () => diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts new file mode 100644 index 00000000000..25a6283f7d5 --- /dev/null +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -0,0 +1,124 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect, FileSystem } from "effect" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" +import { testEffect } from "./lib/effect" + +const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem]))) +const fixture = Effect.gen(function* () { + const fs = yield* FSUtil.Service + const files = yield* FileSystem.FileSystem + const directory = yield* files.makeTempDirectoryScoped() + return { fs, files, directory } +}) + +// kilocode_change - Kilo binds reads to an inspected filesystem identity, so tests resolve a target first +const target = (fs: FSUtil.Interface, at: string) => ReadToolFileSystem.inspect(fs, at) + +describe("ReadToolFileSystem", () => { + it.effect("fails with a typed filesystem error when a resolved file disappears", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "missing.txt") + yield* files.writeFileString(file, "hello") + const resolved = yield* target(fs, file) + yield* files.remove(file) + + const error = yield* ReadToolFileSystem.read(fs, resolved, "missing.txt").pipe(Effect.flip) + + expect(error).toMatchObject({ _tag: "PlatformError" }) + }), + ) + + it.effect("fails when a file becomes the wrong path kind", () => + Effect.gen(function* () { + const { fs, directory } = yield* fixture + + const error = yield* ReadToolFileSystem.read(fs, yield* target(fs, directory), "folder").pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError) + }), + ) + + it.effect("fails with a typed filesystem error when directory listing fails", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "file.txt") + yield* files.writeFileString(file, "hello") + + const error = yield* ReadToolFileSystem.list(fs, yield* target(fs, file)).pipe(Effect.flip) + + expect(error).toBeInstanceOf(FSUtil.FileSystemError) + if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries") + }), + ) + + it.effect("reports binary and malformed UTF-8 content as typed errors", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const binary = path.join(directory, "archive.dat") + const malformed = path.join(directory, "malformed.txt") + yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3)) + const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97) + malformedContent[64 * 1024] = 0x80 + yield* files.writeFile(malformed, malformedContent) + + const binaryError = yield* ReadToolFileSystem.read(fs, yield* target(fs, binary), "archive.dat").pipe(Effect.flip) + const malformedError = yield* ReadToolFileSystem.read(fs, yield* target(fs, malformed), "malformed.txt").pipe(Effect.flip) + + expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError) + expect(binaryError.message).toBe("Cannot read binary file: archive.dat") + expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error) + }), + ) + + it.effect("reports out-of-range pagination as a typed error", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "short.txt") + yield* files.writeFileString(file, "one\n") + + const error = yield* ReadToolFileSystem.read(fs, yield* target(fs, file), "short.txt", { offset: 2 }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError) + expect(error.message).toBe("Offset 2 is out of range") + }), + ) + + it.effect("stops reading after the requested page is complete", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const prefix = new TextEncoder().encode("one\n") + for (const [name, trailing] of [ + ["malformed.txt", 0x80], + ["nul.txt", 0], + ] as const) { + const file = path.join(directory, name) + yield* files.writeFile(file, Uint8Array.from([...prefix, trailing])) + + const result = yield* ReadToolFileSystem.read(fs, yield* target(fs, file), name, { limit: 1 }) + + expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 }) + } + }), + ) + + it.effect("preserves the media ingestion limit message", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "oversized.png") + yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) + yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1) + + const error = yield* ReadToolFileSystem.read(fs, yield* target(fs, file), "oversized.png").pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError) + expect(error.message).toBe( + `Media exceeds ${ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES} byte ingestion limit: oversized.png`, + ) + }), + ) +}) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index b828d298685..e82eddbf1af 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,25 +1,30 @@ import { beforeEach, describe, expect } from "bun:test" import path from "path" -import { Effect, Exit, Layer } from "effect" +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" -import { LocationMutation } from "@opencode-ai/core/location-mutation" import { Image } from "@opencode-ai/core/image" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { AbsolutePath } from "@opencode-ai/core/schema" 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" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const assertions: PermissionV2.AssertInput[] = [] +const missingPath = "__missing_read_target__.txt" +const missingAbsolutePath = path.join(process.cwd(), missingPath) const readCalls: { input: AbsolutePath page: ReadToolFileSystem.PageInput @@ -34,7 +39,7 @@ let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = { encoding: "utf8", mime: "text/plain", } -let readFailure: unknown +let readFailure: ReadToolFileSystem.ReadError | undefined let configEntries: Config.Entry[] = [] const reader = Layer.succeed( ReadToolFileSystem.Service, @@ -45,7 +50,7 @@ const reader = Layer.succeed( : Effect.die(resolveFailure), read: (target, _resource, page = {}) => { readCalls.push({ input: target.path, page }) - if (readFailure !== undefined) return Effect.die(readFailure) + if (readFailure !== undefined) return Effect.fail(readFailure) return Effect.succeed(readResult) }, list: (_path, input = {}) => @@ -70,62 +75,77 @@ 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) => Effect.succeed(FSUtil.Service.of({ ...fs, realPath: (path) => Effect.succeed(path) }))), -).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 }), + FSUtil.Service.use((fs) => + Effect.succeed( + FSUtil.Service.of({ + ...fs, + realPath: (path) => + path === missingAbsolutePath + ? Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: path, + }), + ) + : Effect.succeed(path), + }), + ), + ), +).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, LocationMutation.Service.of({ - resolve: (input) => - Effect.sync(() => { - const canonical = path.resolve(process.cwd(), input.path) - const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical) - const directory = path.dirname(canonical) - return { - canonical, - resource: external ? canonical : path.relative(process.cwd(), canonical), - externalDirectory: external - ? { action: "external_directory", directory, resource: `${directory}/*`, save: `${directory}/*` } - : undefined, - } - }), + resolve: (input) => { + if (input.path === missingPath) + return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" })) + const canonical = path.resolve(process.cwd(), input.path) + const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical) + const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "." + const directory = path.dirname(canonical) + const externalResource = path.join(directory, "*").replaceAll("\\", "/") + return Effect.succeed({ + canonical, + resource, + externalDirectory: external + ? { + action: "external_directory" as const, + directory, + resource: externalResource, + save: externalResource, + } + : undefined, + }) + }, }), ) 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(infrastructure), - Layer.provide(mutation), -) -const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, mutation, read)) -const unavailableRead = ReadTool.layer.pipe( - Layer.provide(registry), - Layer.provide(reader), - Layer.provide(permission), - Layer.provide(config), - Layer.provide(unavailableImage), - Layer.provide(infrastructure), - Layer.provide(mutation), -) -const itWithoutResizer = testEffect( - Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, mutation, unavailableRead), -) +const readLayer = (imageLayer: Layer.Layer) => + 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", () => { @@ -179,25 +199,27 @@ describe("ReadTool", () => { }), ) - it.effect("requires external-directory approval before reading an absolute path", () => + it.effect("asks for external_directory approval before reading an external absolute path", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service - const target = path.resolve("/tmp/external.txt") - const directory = path.dirname(target) - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-external", name: "read", input: { path: "/tmp/external.txt" } }, - }) + const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt") + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } }, + }), + ).toMatchObject({ type: "json" }) expect(assertions).toMatchObject([ { sessionID, action: "external_directory", - resources: [`${directory}/*`], - save: [`${directory}/*`], + resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")], }, - { sessionID, action: "read", resources: [target], save: ["*"] }, + { sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] }, ]) + expect(readCalls).toEqual([{ input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } }]) }), ) @@ -469,9 +491,32 @@ describe("ReadTool", () => { }), ) + it.effect("returns expected filesystem failures to the model", () => + Effect.gen(function* () { + readFailure = new ReadToolFileSystem.BinaryFileError({ resource: "archive.dat" }) + const registry = yield* ToolRegistry.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-binary", + name: "read", + input: { path: "archive.dat", offset: 2, limit: 1 }, + }, + }), + ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) + expect(readCalls).toEqual([ + { input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } }, + ]) + }), + ) + it.effect("preserves unexpected filesystem defects", () => Effect.gen(function* () { - readFailure = new ReadToolFileSystem.BinaryFileError("archive.dat") + resolveFailure = new Error("unexpected") const registry = yield* ToolRegistry.Service expect( @@ -479,18 +524,10 @@ describe("ReadTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { - type: "tool-call", - id: "call-binary", - name: "read", - input: { path: "archive.dat", offset: 2, limit: 1 }, - }, + call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } }, }).pipe(Effect.exit), ), ).toBe(true) - expect(readCalls).toEqual([ - { input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } }, - ]) }), ) @@ -510,6 +547,22 @@ describe("ReadTool", () => { }), ) + it.effect("returns missing paths as model-visible tool failures", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } }, + }), + ).toEqual({ type: "error", value: `Unable to read ${missingPath}` }) + expect(assertions).toEqual([]) + expect(readCalls).toEqual([]) + }), + ) + it.effect("lists a bounded directory page through read", () => Effect.gen(function* () { resolvedType = "directory" diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 8c08454c4f4..4831acb7993 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -1,16 +1,16 @@ import fs from "fs/promises" import path from "path" -import { pathToFileURL } from "url" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV2 } from "@opencode-ai/core/permission" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillTool } from "@opencode-ai/core/tool/skill" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -42,16 +42,6 @@ describe("SkillTool", () => { let current = [info] const assertions: PermissionV2.AssertInput[] = [] let deny = false - let bootWaited = false - const boot = Layer.succeed( - PluginBoot.Service, - PluginBoot.Service.of({ - wait: () => - Effect.sync(() => { - bootWaited = true - }), - }), - ) const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ @@ -69,24 +59,23 @@ describe("SkillTool", () => { const skills = Layer.succeed( SkillV2.Service, SkillV2.Service.of({ - transform: () => Effect.die("unused"), + transform: (_transform) => Effect.die("unused"), + reload: () => Effect.die("unused"), sources: () => Effect.die("unused"), list: () => Effect.succeed(current), }), ) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const tool = SkillTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(boot), - 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, boot, tool) return yield* Effect.gen(function* () { const registry = yield* ToolRegistry.Service - expect(bootWaited).toBe(true) expect((yield* toolDefinitions(registry))[0]).toMatchObject({ name: "skill", description: SkillTool.description, @@ -101,9 +90,7 @@ describe("SkillTool", () => { type: "text", value: SkillTool.toModelOutput(info, [reference]), }) - expect(SkillTool.toModelOutput(info, [reference])).toContain( - `Base directory for this skill: ${pathToFileURL(directory).href}`, - ) + expect(SkillTool.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`) expect( yield* settleTool(registry, { sessionID, @@ -134,7 +121,7 @@ describe("SkillTool", () => { }), ).toEqual({ type: "error", value: "Unable to load skill effect" }) deny = false - const flat = new SkillV2.Info({ + const flat = SkillV2.Info.make({ name: "public", description: "Public guidance", location: AbsolutePath.make(path.join(tmp.path, "public.md")), @@ -154,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)) }), ), ), diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index c8d1799010a..170d1230a3f 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -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,12 +35,22 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos)) -const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool)) +const it = testEffect( + 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* () { assertions.length = 0 @@ -74,7 +87,9 @@ describe("TodoWriteTool", () => { yield* setup const registry = yield* ToolRegistry.Service const service = yield* SessionTodo.Service - const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }] + const todoList: ReadonlyArray = [ + { content: "Implement slice", status: "in_progress", priority: "high" }, + ] expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name]) expect(yield* settleTool(registry, call(todoList))).toEqual({ diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index b2541e3e21f..2fe46f0ae54 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -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 @@ -176,6 +179,25 @@ describe("WebFetchTool registration", () => { }), ) + it.effect("returns an error result when HTML-to-Markdown conversion throws", () => + Effect.gen(function* () { + reset() + respond = () => + Effect.succeed( + new Response("
".repeat(10_000) + "content" + "
".repeat(10_000), { + headers: { "content-type": "text/html" }, + }), + ) + const registry = yield* ToolRegistry.Service + const url = "https://1.1.1.1/deep-html" + + expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({ + type: "error", + value: `Unable to fetch ${url}`, + }) + }), + ) + it.effect("rejects declared and streamed oversized bodies", () => Effect.gen(function* () { reset() diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index dc38a9c3502..0b99a80ecb3 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, test } from "bun:test" +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" @@ -66,8 +70,14 @@ interface Request { const requests: Request[] = [] const assertions: PermissionV2.AssertInput[] = [] let responseBody = payload("search results") +let makeResponse = () => new Response(responseBody, { status: 200 }) let config: WebSearchTool.Config = { enableExa: false, enableParallel: false } +beforeEach(() => { + responseBody = payload("search results") + makeResponse = () => new Response(responseBody, { status: 200 }) +}) + const http = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -78,7 +88,7 @@ const http = Layer.succeed( headers: request.headers, body: JSON.parse(new TextDecoder().decode(request.body.body)), }) - return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 })) + return HttpClientResponse.fromWeb(request, makeResponse()) }), ), ) @@ -93,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({ @@ -114,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", () => @@ -270,7 +283,22 @@ describe("WebSearchTool registration", () => { Effect.gen(function* () { requests.length = 0 assertions.length = 0 - responseBody = "x".repeat(WebSearchTool.MAX_RESPONSE_BYTES + 1) + let chunksRead = 0 + let cancelled = false + makeResponse = () => + new Response( + new ReadableStream({ + pull(controller) { + chunksRead++ + if (chunksRead === 10) throw new Error("response was not stopped at the byte limit") + controller.enqueue(new Uint8Array(64 * 1024)) + }, + cancel() { + cancelled = true + }, + }), + { status: 200 }, + ) config = { provider: "exa", enableExa: false, enableParallel: false } const registry = yield* ToolRegistry.Service @@ -281,6 +309,8 @@ describe("WebSearchTool registration", () => { call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, }), ).toEqual({ type: "error", value: "Unable to search the web for too much" }) + expect(chunksRead).toBeLessThan(10) + expect(cancelled).toBe(true) }), ) }) diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index de5c7c264aa..a45bf73ee44 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -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 = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { 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") => ({ @@ -279,7 +291,7 @@ test("keeps the locked write schema, semantics docstring, and deferred UX TODOs expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.", diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index eb87afb056d..63cece40b72 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -3,9 +3,10 @@ import { spawn } from "child_process" import fs from "fs/promises" import path from "path" import os from "os" -import { Cause, Effect, Exit, Layer } from "effect" +import { Cause, Effect, Exit } from "effect" import { testEffect } from "../lib/effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Global } from "@opencode-ai/core/global" import { Hash } from "@opencode-ai/core/util/hash" @@ -110,7 +111,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]]) // --------------------------------------------------------------------------- // Tests diff --git a/packages/effect-drizzle-sqlite/examples/basic.ts b/packages/effect-drizzle-sqlite/examples/basic.ts index 675aabcb857..80397cd6e6c 100644 --- a/packages/effect-drizzle-sqlite/examples/basic.ts +++ b/packages/effect-drizzle-sqlite/examples/basic.ts @@ -25,7 +25,7 @@ class Database extends Context.Service()("@opencode/exa class UserStoreError extends Schema.TaggedErrorClass()("UserStoreError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} const mapStoreError = (message: string) => (cause: unknown) => new UserStoreError({ message, cause }) diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index e931dce3e17..0b01f0809b4 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -53,11 +53,11 @@ "typescript": "catalog:" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.74", + "@effect/platform-node": "4.0.0-beta.83", "effect": "catalog:", - "@effect/platform-node-shared": "4.0.0-beta.74" + "@effect/platform-node-shared": "4.0.0-beta.83" }, "peerDependencies": { - "effect": "4.0.0-beta.74" + "effect": "4.0.0-beta.83" } } diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md new file mode 100644 index 00000000000..0d31b129b72 --- /dev/null +++ b/packages/httpapi-codegen/README.md @@ -0,0 +1,42 @@ +# @opencode-ai/httpapi-codegen + +Build-time source generation for domain-oriented Promise and Effect APIs derived directly from `HttpApi` and Effect Schema contracts. + +The package is private while its API is explored. Its tests are the executable specification for the generator. It must remain independent of OpenCode Core and use synthetic `HttpApi` fixtures. + +## Settled rules + +- Reflect one authoritative `HttpApi` into a shared contract with `compile(Api)`. +- Emit clients independently with `emitPromise(contract)` and `emitEffect(contract)`. +- Give each emitter its own public type projection; the shared contract, not a generated type package, is the common source. +- Generate a rich Effect client with decoded Effect-native values, runtime schemas, preserved transformations, and `HttpApiClient`. +- Generate a zero-Effect Promise client with structural wire-oriented values, direct `fetch`, and syntax parsing without runtime structural validation. +- Keep the Promise surface domain-oriented rather than Hey API compatible: methods return unwrapped values and reject with tagged declared errors or `ClientError`. +- Return Promise streams as lazy `AsyncIterable` values and Effect streams as `Stream` values. Neither runtime reconnects automatically. + +- Flatten path, query, header, and payload fields into one input object. +- Reject duplicate field names across input channels. +- Emit no method argument for zero fields, an optional object when every field is optional, and a required object when any field is required. +- Unwrap exact `{ data: A }` success envelopes. +- Map no-content success to `void`. +- Preserve other single success values. +- Reject ambiguous multiple-success contracts. +- Expose streaming success as `Stream`, not `Effect`. +- Reject schemas whose wire/domain transformation cannot be generated exactly. +- Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`. +- Commit generated source for review; CI regenerates and fails when the worktree changes. +- Track generated files in `.httpapi-codegen.json` so regeneration removes only stale files previously owned by the generator. + +## Boundary + +This package generates only client APIs derived from `HttpApi`. It does not generate embedded-only capabilities. Networked and embedded OpenCode use the same generated Effect client against network and in-memory `HttpClient` transports respectively; the embedded host structurally extends that client with same-process capabilities. + +Codegen generates every endpoint in the `HttpApi` it receives. OpenCode owns the product decision by composing the exact remote API before invoking the generator; the generic package has no endpoint filtering policy. + +The existing public `generate(Api, { directory })` operation writes the rich Effect output and remains an Effect requiring `FileSystem`. The staged API uses pure `compile(Api)`, `emitEffect(contract)`, and `emitPromise(contract)` phases before `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`. + +Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links. + +Portable Effect output uses one self-contained module per `HttpApiGroup`, plus root client and index modules. Promise output uses shared type and client modules, while imported Effect output keeps adapters in the root client module. Schema dependencies may be duplicated across portable Effect group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it. + +Codegen preserves transport identifiers internally. `compile` may explicitly map consumer-facing group names, and endpoint operation IDs are projected to their final dot-delimited segment. The generator performs no other implicit product-specific naming or public-name annotation mapping. diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json new file mode 100644 index 00000000000..aadefada253 --- /dev/null +++ b/packages/httpapi-codegen/package.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/httpapi-codegen", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test --timeout 5000 --only-failures", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 5000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "effect": "catalog:", + "prettier": "3.6.2" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "version": "7.4.16" +} diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts new file mode 100644 index 00000000000..34df2820ecb --- /dev/null +++ b/packages/httpapi-codegen/src/index.ts @@ -0,0 +1,1190 @@ +import { isAbsolute, join } from "node:path" +import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" +import { HttpMethod, type HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" + +export type InputField = { + readonly name: string + readonly source: "params" | "query" | "headers" | "payload" +} + +export type Operation = { + readonly group: string + readonly name: string + readonly input: ReadonlyArray + readonly inputMode: "none" | "optional" | "required" + readonly success: "value" | "void" | "stream" + readonly errors: ReadonlyArray +} + +export type Output = { + readonly operations: ReadonlyArray + readonly files: ReadonlyArray<{ + readonly path: string + readonly content: string + }> +} + +export type Contract = { + readonly groups: ReadonlyArray +} + +export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { + reason: Schema.String, +}) { + override get message() { + return this.reason + } +} + +export type Endpoint = { + readonly group: string + readonly sourceGroup: string + readonly topLevel: boolean + readonly endpoint: HttpApiEndpoint.AnyWithProps + readonly params: Schema.Top | undefined + readonly query: Schema.Top | undefined + readonly headers: Schema.Top | undefined + readonly payloads: ReadonlyArray + readonly operation: Operation + readonly input: ReadonlyArray + readonly unwrapData: boolean + readonly errors: ReadonlyArray<{ readonly status: number; readonly schema: Schema.Top }> + readonly successes: ReadonlyArray + readonly effectPortable: boolean +} + +export type Group = { + readonly identifier: string + readonly sourceIdentifier: string + readonly module: string + readonly endpoints: ReadonlyArray +} + +type Slot = { + readonly name: string + readonly schema: Schema.Top +} + +const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") +const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const resolveContentSchema = SchemaAST.resolveAt("contentSchema") +const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) +const manifestName = ".httpapi-codegen.json" + +export function compile( + api: HttpApi.HttpApi, + options?: { + readonly groupNames?: Readonly> + readonly endpointNames?: Readonly> + readonly omitEndpoints?: ReadonlySet + }, +): Contract { + const endpoints: Array = [] + const portable = new Map() + + HttpApi.reflect(api, { + onGroup() {}, + onEndpoint({ endpoint, errors, group, middleware }) { + if (options?.omitEndpoints?.has(endpoint.name)) return + const groupName = options?.groupNames?.[group.identifier] ?? group.identifier + const name = `${groupName}.${endpoint.name}` + const required = Array.from(middleware).find((item) => item.requiredForClient) + if (required !== undefined) { + throw new GenerationError({ reason: `Client middleware requires adapter: ${required.key}` }) + } + + const successSchemas = Array.from(endpoint.success) + if (successSchemas.length === 0) successSchemas.push(HttpApiSchema.NoContent) + if (successSchemas.length > 1) throw new GenerationError({ reason: `Multiple success schemas: ${name}` }) + + const params = normalizeTransport(endpoint.params, "params", endpoint, name) + const query = normalizeTransport(endpoint.query, "query", endpoint, name) + const headers = normalizeTransport(endpoint.headers, "headers", endpoint, name) + const sourcePayloads = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas) + if (sourcePayloads.length > 1) { + throw new GenerationError({ reason: `Multiple payload schemas: ${name}` }) + } + const payloads = sourcePayloads.map((schema) => normalizeTransport(schema, "payload", endpoint, name)!) + const success = normalizeTransport(successSchemas[0], "success", endpoint, name)! + const errorSchemas = Array.from(errors).flatMap(([status, schemas]) => + schemas.map((schema) => ({ status, ...normalizeTransport(schema, "error", endpoint, name)! })), + ) + errorSchemas.sort((a, b) => { + const left = SchemaAST.resolveIdentifier(a.schema.ast) ?? "" + const right = SchemaAST.resolveIdentifier(b.schema.ast) ?? "" + return a.status - b.status || (left < right ? -1 : left > right ? 1 : 0) + }) + const inputs = [ + ...inputFields(params?.schema, "params", name), + ...inputFields(query?.schema, "query", name), + ...inputFields(headers?.schema, "headers", name), + ...payloads.flatMap((item) => inputFields(item.schema, "payload", name)), + ] + const names = new Set() + for (const field of inputs) { + if (names.has(field.name)) throw new GenerationError({ reason: `Input field collision: ${field.name}` }) + names.add(field.name) + } + + const schemaPaths: Array = [ + ...(params === undefined ? [] : [[`${name}.params`, params.schema] as const]), + ...(query === undefined ? [] : [[`${name}.query`, query.schema] as const]), + ...(headers === undefined ? [] : [[`${name}.headers`, headers.schema] as const]), + ...payloads.map((item) => [`${name}.payload`, item.schema] as const), + ...responseSchemas(success.schema, `${name}.success`), + ...errorSchemas.map((item) => [`${name}.error.${item.status}`, item.schema] as const), + ] + const effectPortable = + [params, query, headers, ...payloads, success, ...errorSchemas].every( + (item) => item?.effectPortable !== false, + ) && streamEffectPortable(success.schema) + if (effectPortable) { + for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) + } + + endpoints.push({ + group: groupName, + sourceGroup: group.identifier, + topLevel: group.topLevel, + endpoint, + params: params?.schema, + query: query?.schema, + headers: headers?.schema, + payloads: payloads.map((item) => item.schema), + input: inputs, + unwrapData: isDataEnvelope(success.schema), + successes: [success.schema], + errors: errorSchemas.map((item) => ({ status: item.status, schema: item.schema })), + effectPortable, + operation: { + group: groupName, + name: options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name), + input: inputs.map(({ name, source }) => ({ name, source })), + inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", + success: isStreamSchema(success.schema) + ? "stream" + : HttpApiSchema.isNoContent(success.schema.ast) + ? "void" + : "value", + errors: [ + ...new Set([ + ...errorSchemas.flatMap((item) => { + const identifier = SchemaAST.resolveIdentifier(item.schema.ast) + return identifier === undefined ? [] : [identifier] + }), + "ClientError", + ]), + ], + }, + }) + }, + }) + + const modules = new Set(["client", "client-error", "index"]) + const groups = Array.from( + Map.groupBy(endpoints, (endpoint) => endpoint.group), + ([identifier, endpoints], index) => { + if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) { + throw new GenerationError({ reason: `Client group name collision: ${identifier}` }) + } + const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}` + const module = uniqueModule(base, index, modules) + modules.add(module.toLowerCase()) + return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints } + }, + ) + const publicNames = new Set() + for (const group of groups) { + const endpointNames = new Set() + for (const endpoint of group.endpoints) { + if (endpointNames.has(endpoint.operation.name)) { + throw new GenerationError({ + reason: `Client endpoint name collision: ${group.identifier}.${endpoint.operation.name}`, + }) + } + endpointNames.add(endpoint.operation.name) + } + const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.operation.name) : [group.identifier] + for (const name of names) { + if (publicNames.has(name)) throw new GenerationError({ reason: `Client name collision: ${name}` }) + publicNames.add(name) + } + } + return { + groups, + } +} + +export function emitEffect(contract: Contract): Output { + const endpoint = contract.groups.flatMap((group) => group.endpoints).find((endpoint) => !endpoint.effectPortable) + if (endpoint !== undefined) { + throw new GenerationError({ + reason: `Effect schema requires authoritative import: ${endpoint.group}.${endpoint.endpoint.name}`, + }) + } + return { operations: operations(contract.groups), files: renderEffectFiles(contract.groups) } +} + +export function emitEffectImported( + contract: Contract, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output { + return { + operations: operations(contract.groups), + files: renderImportedEffectFiles(contract.groups, options), + } +} + +export function emitPromise( + contract: Contract, + options?: { + readonly outputTypes?: Readonly> + }, +): Output { + const groups = contract.groups + for (const group of groups) { + for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) + } + return { + operations: operations(groups), + files: [ + { path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) }, + { + path: "client-error.ts", + content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + }, + { + path: "client.ts", + content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult", "let next"), + }, + { + path: "index.ts", + content: + 'export { ClientError, type ClientErrorReason } from "./client-error"\nexport * as OpenCode from "./client"\nexport * from "./types"\n', + }, + ], + } +} + +function assertPromiseEndpoint(endpoint: Endpoint) { + const name = `${endpoint.group}.${endpoint.endpoint.name}` + const payload = endpoint.payloads[0] + const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast) + if ( + payload !== undefined && + (payloadEncoding?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded")) !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` }) + } + const success = endpoint.successes[0] + if (isStreamSchema(success)) { + if ( + success._tag !== "StreamSse" || + success.sseMode !== "data" || + !SchemaAST.isNever(Schema.toType(success.error).ast) + ) { + throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` }) + } + } else if ( + !HttpApiSchema.isNoContent(success.ast) && + (resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } + for (const error of endpoint.errors) { + if (declaredErrorFields(error.schema) === undefined) { + throw new GenerationError({ reason: `Promise error must have a literal discriminator: ${name}` }) + } + if ((resolveHttpApiEncoding(error.schema.ast)?._tag ?? "Json") !== "Json") { + throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` }) + } + } +} + +function operations(groups: ReadonlyArray) { + return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation)) +} + +function renderEffectFiles(groups: ReadonlyArray): Output["files"] { + return [ + ...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })), + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: renderClient(groups) }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderImportedEffectFiles( + groups: ReadonlyArray, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output["files"] { + const adapters = groups.map((group, groupIndex) => { + const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` + const methods = group.endpoints.map((item, endpointIndex) => { + const prefix = `Endpoint${groupIndex}_${endpointIndex}` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const fields = item.input.filter((field) => field.source === source) + if (fields.length === 0) return [] + return [ + `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`).join(", ")} }`, + ] + }) + .join(", ") + const input = item.input + .map( + (field) => + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + ) + .join("; ") + const argument = + item.operation.inputMode === "none" + ? "" + : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const rawCall = `raw[${JSON.stringify(item.endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` + }) + return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` + }) + const fields = groups.flatMap((group, index) => + group.endpoints[0]?.topLevel + ? [`...adaptGroup${index}(raw)`] + : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`], + ) + const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream")) + const imported = "api" in options + const projection = imported + ? undefined + : "group" in options + ? renderImportedGroup(options.group) + : renderImportedProjection(groups, options.endpoints) + const api = imported ? options.api : "Api" + const imports = + projection === undefined + ? `import { ${api} } from ${JSON.stringify(options.module)}` + : `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}` + const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : "" + const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nconst adaptClient = (raw: RawClient) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map(adaptClient))\n` + return [ + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: client }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderImportedGroup(group: string) { + return { + imports: [group], + source: `const Api = HttpApi.make("generated").add(${group})\n\n`, + } +} + +function renderImportedProjection(groups: ReadonlyArray, endpoints: Readonly>) { + const imports = groups.flatMap((group) => + group.endpoints.map((endpoint) => { + const name = endpoints[`${group.identifier}.${endpoint.endpoint.name}`] + if (name === undefined) { + throw new GenerationError({ + reason: `Missing imported endpoint: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return name + }), + ) + const source = `const Api = HttpApi.make("generated").${groups + .map((group) => { + const options = group.endpoints[0]?.topLevel ? ", { topLevel: true }" : "" + return `add(HttpApiGroup.make(${JSON.stringify(group.identifier)}${options})${group.endpoints.map((endpoint) => `.add(${endpoints[`${group.identifier}.${endpoint.endpoint.name}`]})`).join("")})` + }) + .join(".")}\n\n` + return { imports: [...new Set(imports)], source } +} + +function renderPromiseTypes( + groups: ReadonlyArray, + outputTypes?: Readonly>, +) { + const types = new Map() + const typeOf = (schema: Schema.Top, decoded = false) => { + const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema) + const cached = types.get(projected.ast) + if (cached !== undefined) return cached + const type = structuralType(projected) + types.set(projected.ast, type) + return type + } + const errors = new Map( + groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => + endpoint.errors.flatMap((error) => { + const tagged = declaredErrorFields(error.schema) + return tagged === undefined ? [] : [[tagged.tag, tagged] as const] + }), + ), + ), + ) + const errorTypes = Array.from(errors.values()).map((error) => { + const fields = error.fields + .map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`) + .join("; ") + return `export type ${error.identifier} = { readonly ${JSON.stringify(error.key)}: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && ${JSON.stringify(error.key)} in value && value[${JSON.stringify(error.key)}] === ${JSON.stringify(error.tag)}` + }) + const operations = groups + .flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const schemas = { + params: endpoint.params, + query: endpoint.query, + headers: endpoint.headers, + payload: endpoint.payloads[0], + } + const input = endpoint.input + .map((field) => { + const schema = schemas[field.source] + if (schema === undefined) + throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]` + }) + .join("; ") + const successSchema = endpoint.successes[0] + const success = + outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ?? + typeOf( + isStreamSchema(successSchema) && successSchema._tag === "StreamSse" + ? successSchema.sseMode === "data" + ? streamEncodedDataSchema(successSchema) + : successSchema.events + : successSchema, + ) + return [ + ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), + `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, + ] + }), + ) + .join("\n\n") + const json = operations.includes("JsonValue") + ? "export type JsonValue = null | boolean | number | string | ReadonlyArray | { readonly [key: string]: JsonValue }" + : "" + const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))] + return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n") +} + +function renderPromiseClient(groups: ReadonlyArray) { + const imports = groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] + }), + ) + const fields = groups.map((group) => { + const methods = group.endpoints.map((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const argument = + endpoint.operation.inputMode === "none" + ? "requestOptions?: RequestOptions" + : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` + const path = promisePath(endpoint.endpoint.path, endpoint.input) + const access = (name: string) => + `input${endpoint.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]` + const part = (source: InputField["source"]) => { + const inputs = endpoint.input.filter((field) => field.source === source) + return inputs.length === 0 + ? undefined + : `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }` + } + const parts = [ + endpoint.query === undefined ? undefined : `query: ${part("query")}`, + endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`, + endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`, + ].filter((value): value is string => value !== undefined) + const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))] + const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }` + if (endpoint.operation.success === "stream") { + const success = endpoint.successes[0] + if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") { + throw new GenerationError({ + reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return `${JSON.stringify(endpoint.operation.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` + } + const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : "" + return `${JSON.stringify(endpoint.operation.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` + }) + if (group.endpoints[0]?.topLevel) return methods.join(", ") + return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` + }) + return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async
(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` +} + +function promiseTypePrefix(group: string, endpoint: string) { + return `${identifierPart(group)}${identifierPart(endpoint)}` +} + +function clientEndpointName(name: string) { + return name.slice(name.lastIndexOf(".") + 1) +} + +function identifierPart(value: string) { + return value + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join("") +} + +function structuralType(schema: Schema.Top) { + const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast])) + if ( + document.artifacts.some( + (artifact) => + artifact._tag !== "Import" || artifact.importDeclaration !== 'import type * as Brand from "effect/Brand"', + ) || + Object.keys(document.references.recursives).length > 0 + ) { + throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) + } + const references = new Map( + document.references.nonRecursives.map((reference) => [reference.$ref, reference.code.Type]), + ) + const expand = (type: string, seen = new Set()): string => { + for (const [reference, value] of references) { + const pattern = `(?/g, "") + .replaceAll("Schema.Json", "JsonValue") +} + +function promisePath(path: string, input: ReadonlyArray) { + if (path.includes("*")) throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${path}` }) + const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name)) + const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean) + const template = segments + .map((segment) => { + if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`") + const name = segment.slice(1) + if (!fields.has(name)) throw new GenerationError({ reason: `Missing path parameter: ${name}` }) + return `\${encodeURIComponent(input.${name})}` + }) + .join("") + return `\`${template}\`` +} + +function uniqueModule(base: string, index: number, modules: ReadonlySet) { + if (!modules.has(base.toLowerCase())) return base + const seed = `${base}-${index}` + let suffix = 0 + while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++ + return `${seed}${suffix === 0 ? "" : `-${suffix}`}` +} + +function normalizeTransport( + schema: Schema.Top | undefined, + source: InputField["source"] | "success" | "error", + endpoint: HttpApiEndpoint.AnyWithProps, + operation: string, +) { + if (schema === undefined) return undefined + if (isStreamSchema(schema)) return { schema, effectPortable: true } as const + if (!metadataPortable(schema.ast, new Set())) { + throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + } + const decoded = Schema.toType(schema) + if (!isPathInput(endpoint.path)) { + throw new GenerationError({ reason: `Invalid endpoint path: ${operation}` }) + } + const rebuilt = HttpApiEndpoint.make(endpoint.method)(endpoint.name, endpoint.path, { + ...(source === "params" ? { params: decoded } : undefined), + ...(source === "query" ? { query: decoded } : undefined), + ...(source === "headers" ? { headers: decoded } : undefined), + ...(source === "payload" ? { payload: decoded } : undefined), + ...(source === "success" ? { success: decoded } : { success: Schema.String }), + ...(source === "error" ? { error: decoded } : undefined), + }) + const normalized = + source === "params" + ? rebuilt.params + : source === "query" + ? rebuilt.query + : source === "headers" + ? rebuilt.headers + : source === "payload" + ? Array.from(rebuilt.payload.values())[0]?.schemas[0] + : source === "success" + ? Array.from(rebuilt.success)[0] + : Array.from(rebuilt.error)[0] + if (normalized === undefined) throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + if (!sameEncoding(schema.ast, normalized.ast)) return { schema, effectPortable: false } as const + return { schema: decoded, effectPortable: true } as const +} + +function isPathInput(path: string): path is HttpRouter.PathInput { + return path === "*" || path.startsWith("/") +} + +function sameEncoding(left: SchemaAST.AST, right: SchemaAST.AST): boolean { + if (left._tag !== right._tag || left.encoding?.length !== right.encoding?.length) return false + if ( + left.encoding?.some((link, index) => { + const other = right.encoding?.[index] + return other === undefined || link.transformation !== other.transformation || !sameEncoding(link.to, other.to) + }) + ) + return false + if (!sameChecks(left.checks, right.checks) || !sameContext(left.context, right.context)) return false + if (SchemaAST.isSuspend(left) && SchemaAST.isSuspend(right)) return sameEncoding(left.thunk(), right.thunk()) + if (SchemaAST.isUnion(left) && SchemaAST.isUnion(right)) { + return ( + left.types.length === right.types.length && + left.types.every((ast, index) => sameEncoding(ast, right.types[index])) + ) + } + if (SchemaAST.isArrays(left) && SchemaAST.isArrays(right)) { + return ( + left.elements.length === right.elements.length && + left.rest.length === right.rest.length && + left.elements.every((ast, index) => sameEncoding(ast, right.elements[index])) && + left.rest.every((ast, index) => sameEncoding(ast, right.rest[index])) + ) + } + if (SchemaAST.isObjects(left) && SchemaAST.isObjects(right)) { + return ( + left.propertySignatures.length === right.propertySignatures.length && + left.indexSignatures.length === right.indexSignatures.length && + left.propertySignatures.every((field, index) => sameEncoding(field.type, right.propertySignatures[index].type)) && + left.indexSignatures.every( + (field, index) => + sameEncoding(field.parameter, right.indexSignatures[index].parameter) && + sameEncoding(field.type, right.indexSignatures[index].type), + ) + ) + } + return true +} + +function sameChecks(left: SchemaAST.Checks | undefined, right: SchemaAST.Checks | undefined): boolean { + if (left?.length !== right?.length) return false + if (left === undefined || right === undefined) return true + return left.every((check, index) => { + const other = right[index] + if (other === undefined || check._tag !== other._tag) return false + if (check._tag === "Filter" && other._tag === "Filter") { + return check.run === other.run && check.aborted === other.aborted + } + return check._tag === "FilterGroup" && other._tag === "FilterGroup" && sameChecks(check.checks, other.checks) + }) +} + +function sameContext(left: SchemaAST.Context | undefined, right: SchemaAST.Context | undefined) { + return left?.isOptional === right?.isOptional && left?.isMutable === right?.isMutable +} + +export function write( + output: Output, + directory: string, +): Effect.Effect { + return Effect.gen(function* () { + const paths = new Set() + const normalizedPaths = new Set() + for (const file of output.files) { + if (!isSafeOutputPath(file.path)) yield* new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + const path = file.path.toLowerCase() + if (normalizedPaths.has(path)) yield* new GenerationError({ reason: `Duplicate output path: ${file.path}` }) + normalizedPaths.add(path) + paths.add(file.path) + } + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(directory, { recursive: true }) + const manifest = join(directory, manifestName) + const previous = (yield* fs.exists(manifest)) + ? yield* fs.readFileString(manifest).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Manifest)), + Effect.mapError(() => new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` })), + ) + : [] + if (previous.some((path) => !isSafeOutputPath(path))) { + yield* new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` }) + } + yield* Effect.forEach( + previous.filter((path) => !paths.has(path)), + (path) => fs.remove(join(directory, path), { force: true }), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + fs.exists(join(directory, file.path)).pipe( + Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.succeed(undefined))), + Effect.flatMap((info) => + info?.type === "SymbolicLink" + ? new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + : Effect.void, + ), + ), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + Effect.tryPromise({ + try: () => format(file.content, { filepath: file.path, parser: "typescript", semi: false, printWidth: 120 }), + catch: (error) => new GenerationError({ reason: `Failed to format ${file.path}: ${String(error)}` }), + }).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))), + { concurrency: 8, discard: true }, + ) + yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n") + }) +} + +function isSafeOutputPath(path: string) { + return path !== manifestName && !isAbsolute(path) && path !== "." && path !== ".." && !/[\\/]/.test(path) +} + +export function generate( + api: HttpApi.HttpApi, + options: { readonly directory: string }, +): Effect.Effect { + return Effect.try({ + try: () => emitEffect(compile(api)), + catch: (error) => (error instanceof GenerationError ? error : new GenerationError({ reason: String(error) })), + }).pipe(Effect.flatMap((output) => write(output, options.directory))) +} + +function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) { + if (schema === undefined) return [] + const ast = Schema.toType(schema).ast + if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) { + throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` }) + } + return ast.propertySignatures.map((field) => { + if (typeof field.name !== "string") { + throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) + } + return { + name: field.name, + source, + optional: SchemaAST.isOptional(field.type), + } + }) +} + +function responseSchemas(schema: Schema.Top, path: string): Array { + if (HttpApiSchema.isNoContent(schema.ast)) return [] + if (!isStreamSchema(schema)) return [[path, schema]] + if (schema._tag === "StreamUint8Array") return [] + const value = schema.sseMode === "data" ? streamDataSchema(schema) : schema.events + return [ + [`${path}.${schema.sseMode}`, value], + [`${path}.error`, schema.error], + ] +} + +function assertPortable(schema: Schema.Top, path: string, portable: Map) { + const visiting = new Set() + const taggedError = taggedErrorFields(schema) + const visit = (ast: SchemaAST.AST): boolean => { + const cached = portable.get(ast) + if (cached !== undefined) return cached + if (visiting.has(ast)) return true + visiting.add(ast) + const result = visitCurrent(ast) + visiting.delete(ast) + portable.set(ast, result) + return result + } + const visitCurrent = (ast: SchemaAST.AST): boolean => { + if (!annotationsPortable(ast.annotations)) return false + if (!checksPortable(ast.checks) || ("encodingChecks" in ast && !checksPortable(ast.encodingChecks))) return false + if (SchemaAST.isDeclaration(ast)) { + return generationPortable(ast.annotations?.generation) && ast.typeParameters.every(visit) + } + if (ast.encoding !== undefined && ast.annotations?.generation === undefined) return false + if (SchemaAST.isSuspend(ast)) return visit(ast.thunk()) + if (SchemaAST.isUnion(ast)) return ast.types.every(visit) + if (SchemaAST.isArrays(ast)) { + return ast.elements.every(visit) && ast.rest.every(visit) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => visit(field.type)) && + ast.indexSignatures.every((index) => visit(index.parameter) && visit(index.type)) + ) + } + if (SchemaAST.isTemplateLiteral(ast)) return ast.parts.every(visit) + return true + } + if (taggedError !== undefined && SchemaAST.isDeclaration(schema.ast)) { + if ( + schema.ast.checks !== undefined || + ("encodingChecks" in schema.ast && !checksPortable(schema.ast.encodingChecks)) || + schema.ast.typeParameters.some((ast) => ast.checks !== undefined) || + !schema.ast.typeParameters.every(visit) + ) { + throw new GenerationError({ reason: `Unportable schema: ${path}` }) + } + return + } + if (!visit(schema.ast)) throw new GenerationError({ reason: `Unportable schema: ${path}` }) +} + +function checksPortable(checks: SchemaAST.Checks | undefined): boolean { + if (checks === undefined) return true + return checks.every((check) => + check._tag === "Filter" + ? !check.aborted && + check.annotations?.meta !== undefined && + typeof check.annotations.arbitrary === "object" && + check.annotations.arbitrary !== null && + "constraint" in check.annotations.arbitrary + : checksPortable(check.checks), + ) +} + +function metadataPortable(ast: SchemaAST.AST, seen: Set): boolean { + if (seen.has(ast)) return true + seen.add(ast) + if (!annotationsPortable(ast.annotations) || !checksPortable(ast.checks)) return false + if ("encodingChecks" in ast && !checksPortable(ast.encodingChecks)) return false + if (ast.encoding?.some((link) => !metadataPortable(link.to, seen))) return false + if (SchemaAST.isDeclaration(ast)) return ast.typeParameters.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isSuspend(ast)) return metadataPortable(ast.thunk(), seen) + if (SchemaAST.isUnion(ast)) return ast.types.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isArrays(ast)) { + return ( + ast.elements.every((item) => metadataPortable(item, seen)) && + ast.rest.every((item) => metadataPortable(item, seen)) + ) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => metadataPortable(field.type, seen)) && + ast.indexSignatures.every( + (field) => metadataPortable(field.parameter, seen) && metadataPortable(field.type, seen), + ) + ) + } + return true +} + +function generationPortable(generation: unknown): boolean { + if (typeof generation !== "object" || generation === null) return false + const value = generation as { + readonly runtime?: unknown + readonly Type?: unknown + readonly importDeclaration?: unknown + } + if (typeof value.runtime !== "string" || typeof value.Type !== "string") return false + if (value.importDeclaration !== undefined) { + if ( + typeof value.importDeclaration !== "string" || + !/from ["']effect(?:\/[^"']+)?["']$/.test(value.importDeclaration) + ) { + return false + } + } + const namespace = + typeof value.importDeclaration === "string" + ? /import(?: type)? \* as ([A-Za-z_$][\w$]*)/.exec(value.importDeclaration)?.[1] + : undefined + return value.runtime.startsWith("Schema.") || (namespace !== undefined && value.runtime.startsWith(`${namespace}.`)) +} + +function annotationsPortable(annotations: Schema.Annotations.Annotations | undefined) { + if (annotations === undefined) return true + return Object.entries(annotations).every(([key, value]) => { + if ( + ["toCodec", "toCodecJson", "toArbitrary", "toFormatter", "toEquivalence", "~effect/Schema/Class"].includes(key) + ) { + return true + } + if (key === "generation") return generationPortable(value) + return serializable(value) + }) +} + +function serializable(value: unknown): boolean { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) return true + if (Array.isArray(value)) return value.every(serializable) + if (typeof value !== "object") return false + return Object.values(value).every(serializable) +} + +function taggedErrorFields(schema: Schema.Top) { + const fields = declaredErrorFields(schema) + return fields?.key === "_tag" ? fields : undefined +} + +function declaredErrorFields(schema: Schema.Top) { + if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) { + return undefined + } + const fields = schema.ast.typeParameters[0] + if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined + const key = fields.propertySignatures.find((field) => field.name === "_tag" || field.name === "name")?.name + if (key !== "_tag" && key !== "name") return undefined + const tag = fields.propertySignatures.find((field) => field.name === key)?.type + if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined + return { + key, + tag: tag.literal, + identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal, + fields: fields.propertySignatures.flatMap((field) => + field.name === key || typeof field.name !== "string" + ? [] + : [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const], + ), + } +} + +function isDataEnvelope(schema: Schema.Top) { + if (isStreamSchema(schema) || HttpApiSchema.isNoContent(schema.ast)) return false + const ast = Schema.toType(schema).ast + return ( + SchemaAST.isObjects(ast) && + ast.indexSignatures.length === 0 && + ast.propertySignatures.length === 1 && + ast.propertySignatures[0]?.name === "data" + ) +} + +function isStreamSchema(schema: Schema.Top): schema is HttpApiSchema.StreamSchema { + return "_tag" in schema && (schema._tag === "StreamSse" || schema._tag === "StreamUint8Array") +} + +function streamDataSchema(schema: Extract) { + return Schema.make(streamDataAst(Schema.toType(schema.events).ast)) +} + +function streamEncodedDataSchema(schema: Extract) { + const data = streamDataAst(schema.events.ast) + const encodedAst = data.encoding?.at(-1)?.to + if (encodedAst === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const encoded = resolveContentSchema(encodedAst) + if (!SchemaAST.isAST(encoded)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return Schema.make(encoded) +} + +function streamDataAst(ast: SchemaAST.AST) { + if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const data = ast.propertySignatures.find((field) => field.name === "data")?.type + if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return data +} + +function streamEffectPortable(schema: Schema.Top) { + if (!isStreamSchema(schema) || schema._tag === "StreamUint8Array" || schema.sseMode === "events") return true + const rebuilt = HttpApiSchema.StreamSse({ + data: streamDataSchema(schema), + error: schema.error, + contentType: schema.contentType, + }) + return sameEncoding(schema.events.ast, rebuilt.events.ast) +} + +function renderGroup(group: Group, groupIndex: number) { + const slots: Array = [] + const adapters: Array = [] + const endpointSources = group.endpoints.map((operation, endpointIndex) => { + const { + endpoint, + errors, + headers: endpointHeaders, + params: endpointParams, + payloads: endpointPayloads, + query: endpointQuery, + successes, + } = operation + const prefix = `Endpoint${endpointIndex}` + const params = addSlot(endpointParams, `${prefix}Params`) + const query = addSlot(endpointQuery, `${prefix}Query`) + const headers = addSlot(endpointHeaders, `${prefix}Headers`) + const payloads = endpointPayloads.map((schema, index) => addSlot(schema, `${prefix}Payload${index}`)!) + const success = renderSuccess(successes[0], `${prefix}Success`) + const errorSlots = errors.map((error, index) => addSlot(error.schema, `${prefix}Error${index}`)!) + const options = [ + params === undefined ? undefined : `params: ${params.name}`, + query === undefined ? undefined : `query: ${query.name}`, + headers === undefined ? undefined : `headers: ${headers.name}`, + payloads.length === 0 + ? undefined + : `payload: ${payloads.length === 1 ? payloads[0].name : `[${payloads.map((slot) => slot.name).join(", ")}]`}`, + `success: ${success.source}`, + errorSlots.length === 0 + ? undefined + : `error: ${errorSlots.length === 1 ? errorSlots[0].name : `[${errorSlots.map((slot) => slot.name).join(", ")}]`}`, + ].filter((option): option is string => option !== undefined) + const schemaBySource = { params, query, headers, payload: payloads[0] } + const inputType = operation.input + .map((field) => { + const slot = schemaBySource[field.source] + if (slot === undefined) { + throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.name}` }) + } + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]` + }) + .join("; ") + const argument = + operation.operation.inputMode === "none" + ? "" + : `input${operation.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const slot = schemaBySource[source] + if (slot === undefined) return [] + const fields = operation.input + .filter((field) => field.source === source) + .map( + (field) => + `${JSON.stringify(field.name)}: input${operation.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`, + ) + return [`${source}: { ${fields.join(", ")} }`] + }) + .join(", ") + const declared = [...errorSlots, ...(success.streamError === undefined ? [] : [success.streamError])] + const declaredSchema = + declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])` + const rawCall = `raw[${JSON.stringify(endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n` + adapters.push( + `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`, + ) + return `HttpApiEndpoint.make(${JSON.stringify(endpoint.method)})(${JSON.stringify(endpoint.name)}, ${JSON.stringify(endpoint.path)}, { ${options.join(", ")} })` + }) + + function addSlot(schema: Schema.Top | undefined, name: string) { + if (schema === undefined) return undefined + const slot = { name, schema } + slots.push(slot) + return slot + } + + function renderSuccess(schema: Schema.Top, name: string) { + if (!isStreamSchema(schema)) return { source: addSlot(schema, name)!.name } + const status = resolveHttpApiStatus(schema.ast) ?? 200 + const annotate = status === 200 ? "" : `.pipe(HttpApiSchema.status(${status}))` + if (schema._tag === "StreamUint8Array") { + return { + source: `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + } + } + const value = addSlot( + schema.sseMode === "data" ? streamDataSchema(schema) : schema.events, + `${name}${schema.sseMode === "data" ? "Data" : "Events"}`, + )! + const error = addSlot(schema.error, `${name}Error`)! + return { + source: `HttpApiSchema.StreamSse({ ${schema.sseMode}: ${value.name}, error: ${error.name}, contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + streamError: error, + } + } + + const declarations = renderSchemas(slots) + const groupSource = `HttpApiGroup.make(${JSON.stringify(group.identifier)}, { topLevel: ${group.endpoints[0]?.topLevel ?? false} })${endpointSources.map((endpoint) => `.add(${endpoint})`).join("")}` + const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema.")) + const methods = group.endpoints + .map((item, index) => `${JSON.stringify(item.operation.name)}: Endpoint${index}(raw)`) + .join(", ") + const rawGroup = group.endpoints[0]?.topLevel + ? `HttpApiClient.Client` + : `HttpApiClient.Client.Group` + const usesStream = group.endpoints.some((item) => item.operation.success === "stream") + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n` +} + +function renderSchemas(slots: ReadonlyArray) { + if (slots.length === 0) return "" + const classes = new Map( + slots.flatMap((slot, index) => { + const tagged = taggedErrorFields(slot.schema) + return tagged === undefined ? [] : [[index, tagged] as const] + }), + ) + const expanded = [ + ...slots.map((slot, index) => (classes.has(index) ? { name: slot.name, schema: Schema.Never } : slot)), + ...Array.from(classes.values()).flatMap((tagged, classIndex) => + tagged.fields.map(([name, schema]) => ({ name: `Class${classIndex}${name}`, schema })), + ), + ] + const [first, ...rest] = expanded + const document = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.fromASTs([first.schema.ast, ...rest.map((slot) => slot.schema.ast)]), + ) + const artifacts = document.artifacts.flatMap((artifact) => { + if (artifact._tag === "Import") return [artifact.importDeclaration] + if (artifact._tag === "Enum") return [artifact.generation.runtime] + return [`const ${artifact.identifier} = ${artifact.generation.runtime}`] + }) + const references = [ + ...document.references.nonRecursives.map(({ $ref, code }) => `const ${$ref} = ${code.runtime}`), + ...Object.entries(document.references.recursives).map( + ([$ref, code]) => `type ${$ref} = ${code.Type}\nconst ${$ref}: Schema.Codec<${$ref}> = ${code.runtime}`, + ), + ] + let fieldIndex = slots.length + const declarations = slots.map((slot, index) => { + const tagged = classes.get(index) + if (tagged === undefined) return `const ${slot.name} = ${document.codes[index].runtime}` + const fields = tagged.fields + .map(([name]) => `${JSON.stringify(name)}: ${document.codes[fieldIndex++].runtime}`) + .join(", ") + const annotations = Object.entries({ + httpApiStatus: resolveHttpApiStatus(slot.schema.ast), + "~httpApiEncoding": resolveHttpApiEncoding(slot.schema.ast), + }).filter((entry) => entry[1] !== undefined) + const annotate = + annotations.length === 0 + ? "" + : `.annotate({ ${annotations.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(", ")} })` + return `class ${slot.name}Class extends Schema.TaggedErrorClass<${slot.name}Class>(${JSON.stringify(tagged.identifier)})(${JSON.stringify(tagged.tag)}, { ${fields} }) {}\nconst ${slot.name} = ${slot.name}Class${annotate}` + }) + return [...artifacts, ...references, ...declarations].join("\n\n") +} + +function renderClient(groups: ReadonlyArray) { + const imports = groups + .map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`) + .join("\n") + const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}` + const fields = groups.flatMap((group, index) => { + if (!group.endpoints[0]?.topLevel) { + return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`] + } + const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.name)}: raw[${JSON.stringify(item.endpoint.name)}]`).join(", ")} }` + return [`...adaptGroup${index}(${raw})`] + }) + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n` +} diff --git a/packages/httpapi-codegen/sst-env.d.ts b/packages/httpapi-codegen/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/packages/httpapi-codegen/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/httpapi-codegen/test/effect.ts b/packages/httpapi-codegen/test/effect.ts new file mode 100644 index 00000000000..3accad3cc93 --- /dev/null +++ b/packages/httpapi-codegen/test/effect.ts @@ -0,0 +1,28 @@ +import { test } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type { Scope } from "effect/Scope" +import { TestClock, TestConsole } from "effect/testing" + +type Body = Effect.Effect | (() => Effect.Effect) + +const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +const effect = (name: string, body: Body, options?: Parameters[2]) => + test( + name, + () => + Effect.gen(function* () { + const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe( + Effect.scoped, + Effect.provide(layer), + Effect.exit, + ) + if (Exit.isFailure(exit)) { + yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true }) + } + return yield* exit + }).pipe(Effect.runPromise), + options, + ) + +export const it = { effect } diff --git a/packages/httpapi-codegen/test/fixture.ts b/packages/httpapi-codegen/test/fixture.ts new file mode 100644 index 00000000000..9fb7fedda10 --- /dev/null +++ b/packages/httpapi-codegen/test/fixture.ts @@ -0,0 +1,45 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" + +export class Missing extends Schema.TaggedErrorClass()("Missing", { + message: Schema.String, +}) {} + +export const Api = HttpApi.make("fixture") + .add( + HttpApiGroup.make("session") + .add(HttpApiEndpoint.get("health", "/session/health", { success: Schema.String })) + .add( + HttpApiEndpoint.get("list", "/session", { + query: { archived: Schema.optional(Schema.Boolean) }, + success: Schema.Array(Schema.String), + }), + ) + .add( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ) + .add( + HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { + params: { sessionID: Schema.String }, + success: HttpApiSchema.NoContent, + }), + ), + ) + .add( + HttpApiGroup.make("event").add( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }).pipe( + HttpApiSchema.status(202), + ), + }), + ), + ) + .add( + HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.get("status", "/status", { success: Schema.String }), + ), + ) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts new file mode 100644 index 00000000000..ea2f4ff90ee --- /dev/null +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -0,0 +1,1038 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" // kilocode_change - convert file URLs correctly on Windows +import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" +import { + compile as compileContract, + emitEffect, + emitEffectImported, + emitPromise, + generate, + GenerationError, +} from "../src" +import { it } from "./effect" +import { Api as FixtureApi, Missing } from "./fixture" + +function api(endpoint: HttpApiEndpoint.Any) { + return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint)) +} + +function compile(source: HttpApi.HttpApi) { + return emitEffect(compileContract(source)) +} + +describe("HttpApiCodegen.generate", () => { + test("orders endpoint errors deterministically", () => { + class Alpha extends Schema.TaggedErrorClass()("Alpha", {}) {} + class Beta extends Schema.TaggedErrorClass()("Beta", {}) {} + const endpoint = (errors: readonly [typeof Alpha, typeof Beta] | readonly [typeof Beta, typeof Alpha]) => + HttpApiEndpoint.get("get", "/session", { + success: Schema.String, + error: errors.map((error) => error.pipe(HttpApiSchema.status(400))), + }) + + const alpha = compileContract(api(endpoint([Alpha, Beta]))) + const beta = compileContract(api(endpoint([Beta, Alpha]))) + + expect(emitPromise(alpha)).toEqual(emitPromise(beta)) + }) + + test("compiles one contract for Promise and Effect emitters", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + const promise = emitPromise(contract) + const effect = emitEffect(contract) + + expect(promise.operations).toEqual(effect.operations) + expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"]) + const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content + expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)') + expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`") + expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain( + 'params: { "sessionID": input["sessionID"] }', + ) + }) + + test("allows Promise outputs to use an authoritative imported wire type", () => { + const contract = compileContract( + api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })), + ) + const output = emitPromise(contract, { + outputTypes: { + "session.events": { + name: "EventWire", + import: 'import type { EventWire } from "./event-wire"', + }, + }, + }) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('import type { EventWire } from "./event-wire"') + expect(types).toContain("export type SessionEventsOutput = EventWire") + }) + + test("emits an Effect client against an imported authoritative API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", api: "Api" }, + ) + + expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"]) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + 'import { Api } from "@example/api"', + ) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + "HttpApiClient.ForApi", + ) + }) + + test("projects imported endpoint constants into a generated API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", endpoints: { "session.get": "SessionGet" } }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGet } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))') + }) + + test("imports an authoritative group without reconstructing it", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ), + { module: "@example/api", group: "SessionGroup" }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGroup } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)') + expect(client).not.toContain("HttpApiGroup") + }) + + test("separates hosted and consumer group names", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.session").add( + HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }), + ), + ) + const contract = compileContract(source, { groupNames: { "server.session": "sessions" } }) + + expect(contract.groups[0]?.identifier).toBe("sessions") + expect(contract.groups[0]?.sourceIdentifier).toBe("server.session") + expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" }) + }) + + test("supports explicit public endpoint names", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.permission") + .add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String })) + .add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })), + ) + const contract = compileContract(source, { + endpointNames: { "permission.request.list": "listRequests" }, + }) + + expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"]) + }) + + test("omits custom transport endpoints", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.pty") + .add(HttpApiEndpoint.get("pty.get", "/pty", { success: Schema.String })) + .add(HttpApiEndpoint.get("pty.connect", "/pty/connect", { success: Schema.Boolean })), + ) + const contract = compileContract(source, { omitEndpoints: new Set(["pty.connect"]) }) + + expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.endpoint.name)).toEqual(["pty.get"]) + }) + + test("uses bracket access for input field names", () => { + const source = api( + HttpApiEndpoint.post("token", "/token", { + headers: { "x-example-token": Schema.Literal("1") }, + success: Schema.String, + }), + ) + const contract = compileContract(source) + const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content + const effect = emitEffectImported(contract, { + module: "@example/api", + endpoints: { "session.token": "Token" }, + }).files.find((file) => file.path === "client.ts")?.content + + expect(promise).toContain('"x-example-token": input["x-example-token"]') + expect(effect).toContain('"x-example-token": input["x-example-token"]') + }) + + test("rejects consumer group name collisions", () => { + const source = HttpApi.make("test") + .add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String }))) + .add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String }))) + + expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow( + "Client group name collision: same", + ) + }) + + test("uses the unqualified endpoint name for the public client", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("session.get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ) + const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content + const effect = emitEffectImported(contract, { + module: "@example/api", + endpoints: { "session.session.get": "SessionGet" }, + }).files.find((file) => file.path === "client.ts")?.content + + expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get") + expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)') + expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })') + expect(effect).toContain('raw["session.get"]') + }) + + test("preserves optional keys in Promise error types", () => { + class OptionalError extends Schema.TaggedErrorClass()( + "OptionalError", + { message: Schema.String, detail: Schema.String.pipe(Schema.optional) }, + { httpApiStatus: 400 }, + ) {} + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'readonly "message": string; readonly "detail"?: string | undefined', + ) + }) + + test("supports name-discriminated Promise errors", () => { + class NamedError extends Schema.ErrorClass("NamedError")( + { name: Schema.Literal("NamedError"), message: Schema.String }, + { httpApiStatus: 400 }, + ) {} + const output = emitPromise( + compileContract( + api(HttpApiEndpoint.get("get", "/session", { success: Schema.NumberFromString, error: NamedError })), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('readonly "name": "NamedError"') + expect(types).toContain('"name" in value && value["name"] === "NamedError"') + }) + + test("preserves reflected default error statuses", () => { + class MissingStatus extends Schema.TaggedErrorClass()("MissingStatus", { + message: Schema.String, + }) {} + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: MissingStatus }))), + ) + + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain("declaredStatuses: [500]") + }) + + test("erases brands from Promise wire types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) }, + success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }), + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('readonly "sessionID": string') + expect(types).not.toContain("Brand") + }) + + test("inlines non-recursive references in Promise wire types", () => { + const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" }) + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Struct({ data: Referenced }), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]', + ) + }) + + test("expands Promise references only at identifier boundaries", () => { + const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({ + identifier: "Session", + }) + const SessionID = Schema.String.annotate({ identifier: "SessionID" }) + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Struct({ session: Session, sessionID: SessionID }), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'readonly "session": ({ readonly "name": "Session", readonly "id": string })', + ) + }) + + test("emits Effect Json schemas as standalone Promise types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Json, + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain("export type JsonValue =") + expect(types).toContain("{ readonly [key: string]: JsonValue }") + expect(types).not.toContain("Schema.Json") + }) + + test("emits an optional Promise input when every field is optional", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("list", "/session", { + query: { limit: Schema.optional(Schema.Number) }, + success: Schema.Array(Schema.String), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + '"list": (input?: SessionListInput, requestOptions?: RequestOptions)', + ) + }) + + test("rejects Promise transports that are not implemented", () => { + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("text", "/text", { + success: Schema.String.pipe(HttpApiSchema.asText()), + }), + ), + ), + ), + ).toThrow("Unsupported Promise success encoding: session.text") + + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("binary", "/binary", { + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }), + ), + ), + ), + ).toThrow("Unsupported Promise success encoding: session.binary") + + expect(() => + emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*", { success: Schema.String })))), + ).toThrow("Unsupported Promise path wildcard: /file/*") + + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("events", "/events", { + success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }), + }), + ), + ), + ), + ).toThrow("Unsupported Promise stream: session.events") + }) + + test("executes an emitted Promise GET through fetch", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + request = input instanceof Request ? input : new Request(input) + return Response.json({ data: "hello" }) + }, + }) + + expect(await client.session.get({ sessionID: "a/b" })).toBe("hello") + expect(request?.method).toBe("GET") + expect(request?.url).toBe("https://example.com/session/a%2Fb") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("maps an emitted no-content response to undefined", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { + params: { sessionID: Schema.String }, + success: HttpApiSchema.NoContent, + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => new Response(null, { status: 204 }), + }) + + expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("serializes flattened query, header, and JSON payload inputs", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.post("prompt", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { resume: Schema.optional(Schema.Boolean) }, + headers: { traceID: Schema.String }, + payload: Schema.Struct({ prompt: Schema.String }), + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ data: "admitted" }) + }, + }) + + expect( + await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }), + ).toBe("admitted") + expect(request?.url).toBe("https://example.com/session/session?resume=true") + expect(request?.headers.get("traceID")).toBe("trace") + expect(await request?.json()).toEqual({ prompt: "hello" }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("rejects with declared tagged errors and exports a type guard", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }), + }) + + const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause) + expect(error).toEqual({ _tag: "Missing", message: "gone" }) + expect(generated.isMissing(error)).toBeTrue() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("iterates an emitted SSE stream lazily without reconnecting", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("subscribe", "/event", { + query: { after: Schema.optional(Schema.Number) }, + success: HttpApiSchema.StreamSse({ + data: Schema.Struct({ type: Schema.String, count: Schema.NumberFromString }), + }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let requests = 0 + let url: string | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + requests++ + url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r')) + controller.enqueue(encoder.encode("\n\r\n")) + controller.close() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + const events = client.session.subscribe({ after: 2 }) + + expect(requests).toBe(0) + const received = [] + for await (const event of events) received.push(event) + expect(received).toEqual([{ type: "ready", count: "1" }]) + expect(requests).toBe(1) + expect(url).toBe("https://example.com/event?after=2") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("preserves public group and endpoint identifiers exactly", () => { + const output = compile( + HttpApi.make("test").add( + HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })), + ), + ) + + expect(output.operations[0]).toMatchObject({ group: "session", name: "get" }) + }) + + test("emits one client module per HttpApi group", () => { + const source = HttpApi.make("test") + .add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))) + .add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String }))) + + const output = compile(source) + + expect(output.files.map((file) => file.path)).toEqual([ + "session.ts", + "tool.ts", + "client-error.ts", + "client.ts", + "index.ts", + ]) + }) + + test("emits syntactically valid TypeScript modules", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + const transpiler = new Bun.Transpiler({ loader: "ts" }) + + for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow() + }) + + it.effect("keeps the strict generated-consumer fixture current", () => + Effect.gen(function* () { + const output = compile(FixtureApi) + const actual = yield* Effect.promise( + () => Array.fromAsync(new Bun.Glob("*.ts").scan(fileURLToPath(new URL("generated", import.meta.url)))), // kilocode_change + ) + expect(actual.sort((a, b) => a.localeCompare(b))).toEqual( + output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)), + ) + yield* Effect.forEach(output.files, (file) => + Effect.tryPromise(() => + Promise.all([ + Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(), + format(file.content, { parser: "typescript", semi: false, printWidth: 120 }), + ]), + ).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))), + ) + }), + ) + + test("flattens transport input channels into one domain input", () => { + const output = compile( + api( + HttpApiEndpoint.post("prompt", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { resume: Schema.String }, + headers: { traceID: Schema.String }, + payload: Schema.Struct({ prompt: Schema.String }), + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + expect(output.operations[0]?.input).toEqual([ + { name: "sessionID", source: "params" }, + { name: "resume", source: "query" }, + { name: "traceID", source: "headers" }, + { name: "prompt", source: "payload" }, + ]) + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'params: { "sessionID": input["sessionID"] }', + ) + }) + + test("uses no argument when an operation has no input fields", () => { + const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String }))) + + expect(output.operations[0]?.inputMode).toBe("none") + }) + + test("uses an optional object when every input field is optional", () => { + const output = compile( + api( + HttpApiEndpoint.get("list", "/session", { + query: { limit: Schema.optional(Schema.String) }, + success: Schema.Array(Schema.String), + }), + ), + ) + + expect(output.operations[0]?.inputMode).toBe("optional") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]') + }) + + test("regenerates standard HttpApi transport codecs from decoded schemas", () => { + const output = compile( + api( + HttpApiEndpoint.get("list", "/session", { + query: { archived: Schema.optional(Schema.Boolean) }, + success: Schema.String, + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean") + }) + + test("uses a required object when any input field is required", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { includeArchived: Schema.optional(Schema.String) }, + success: Schema.String, + }), + ), + ) + + expect(output.operations[0]?.inputMode).toBe("required") + }) + + test("rejects colliding input names across transport channels", () => { + expect(() => + compile( + api( + HttpApiEndpoint.post("prompt", "/session/:id", { + params: { id: Schema.String }, + payload: Schema.Struct({ id: Schema.String }), + success: Schema.Void, + }), + ), + ), + ).toThrow("Input field collision: id") + }) + + test("rejects multiple payload alternatives until selection semantics are explicit", () => { + expect(() => + compile( + api( + HttpApiEndpoint.post("prompt", "/session", { + payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })], + success: Schema.String, + }), + ), + ), + ).toThrow("Multiple payload schemas: session.prompt") + }) + + test("unwraps an exact data success envelope", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + expect(output.operations[0]?.success).toBe("value") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + "Effect.map((value) => value.data)", + ) + }) + + test("maps no-content success to void", () => { + const output = compile( + api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })), + ) + + expect(output.operations[0]?.success).toBe("void") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204') + }) + + test("preserves non-default empty response statuses", () => { + const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created }))) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201') + }) + + test("returns a non-envelope success unchanged", () => { + const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String }))) + + expect(output.operations[0]?.success).toBe("value") + }) + + test("rejects multiple success shapes until their public semantics are explicit", () => { + expect(() => + compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: [Schema.String, Schema.Number], + }), + ), + ), + ).toThrow("Multiple success schemas: session.get") + }) + + test("models an SSE success as a direct stream", () => { + const output = compile( + api( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }), + }), + ), + ) + + expect(output.operations[0]?.success).toBe("stream") + }) + + test("preserves annotated stream response statuses", () => { + const output = compile( + api( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)), + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + ".pipe(HttpApiSchema.status(202))", + ) + }) + + test("rejects schemas whose semantics cannot be emitted exactly", () => { + const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow( + "Unportable schema: session.get.success", + ) + }) + + test("rejects custom transformations hidden beneath standard HttpApi codecs", () => { + const QueryBoolean = Schema.Literals(["yes", "no"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "yes"), + encode: SchemaGetter.transform((value) => (value ? "yes" : "no")), + }), + ) + + expect(() => + compile( + api( + HttpApiEndpoint.get("get", "/session", { + query: { archived: QueryBoolean }, + success: Schema.String, + }), + ), + ), + ).toThrow("Effect schema requires authoritative import: session.get") + }) + + test("rejects custom validation checks without portable metadata", () => { + const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive"))) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow( + "Unportable schema: session.get.success", + ) + }) + + test("rejects spoofed and aborted validation checks", () => { + const Spoofed = Schema.Number.check( + Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }), + ) + const Aborted = Schema.Number.check(Schema.isFinite().abort()) + + expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow( + "Unportable schema: session.spoofed.success", + ) + expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow( + "Unportable schema: session.aborted.success", + ) + }) + + test("rejects altered wire-side schemas even when the codec transformation is canonical", () => { + const JsonNumber = Schema.toCodecJson(Schema.Number) + const link = JsonNumber.ast.encoding?.[0] + if (link === undefined) throw new Error("Expected JSON number encoding") + // This helper is present at runtime but omitted from the public declaration surface. + const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding") + if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding") + const ast: unknown = replaceEncoding(JsonNumber.ast, [ + new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation), + ]) + if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST") + const Altered = Schema.make(ast) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow( + "Effect schema requires authoritative import: session.get", + ) + }) + + test("rejects lexical generation and annotation values", () => { + const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({ + generation: { runtime: "LocalOnly", Type: "string" }, + }) + const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({ + custom: () => "local", + }) + + expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow( + "Unportable schema: session.generated.success", + ) + expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow( + "Unportable schema: session.annotated.success", + ) + }) + + test("preserves errors from server-only middleware", () => { + class Unauthorized extends Schema.TaggedErrorClass()("Unauthorized", {}) {} + class Authorization extends HttpApiMiddleware.Service()("Authorization", { + error: Unauthorized, + }) {} + + const output = compile( + api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)), + ) + + expect(output.operations[0]).toBeDefined() + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'extends Schema.TaggedErrorClass("Unauthorized")', + ) + }) + + test("preserves tagged error response statuses", () => { + class Missing extends Schema.TaggedErrorClass()("Missing", {}) {} + const output = compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.String, + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })', + ) + }) + + test("supports every HttpApi method through the generic constructor", () => { + const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String }))) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")') + }) + + test("uses safe unique module paths without changing public group identifiers", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))) + .add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"]) + expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"') + }) + + test("reserves support module names case-insensitively", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String }))) + .add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"]) + }) + + test("keeps searching when a reserved-name fallback is also occupied", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))) + .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"]) + }) + + test("rejects collisions in the flattened client namespace", () => { + expect(() => + compile( + HttpApi.make("test") + .add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String }))) + .add( + HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.get("status", "/status", { success: Schema.String }), + ), + ), + ), + ).toThrow("Client name collision: status") + }) + + test("emits a usable raw type for top-level groups", () => { + const output = compile( + HttpApi.make("test").add( + HttpApiGroup.make("health", { topLevel: true }).add( + HttpApiEndpoint.get("check", "/health", { success: Schema.String }), + ), + ), + ) + + expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client + Effect.gen(function* () { + const error = yield* generate( + api( + HttpApiEndpoint.get("get", "/url", { + success: Schema.declare((input): input is URL => input instanceof URL), + }), + ), + { + directory: "/generated", + }, + ).pipe(Effect.flip) + + expect(error).toBeInstanceOf(GenerationError) + if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success") + }).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))), + ) + + test("rejects required client middleware without an adapter", () => { + class SignedRequest extends HttpApiMiddleware.Service()("SignedRequest", { + requiredForClient: true, + }) {} + + expect(() => + compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))), + ).toThrow("Client middleware requires adapter: SignedRequest") + }) + + test("maps transport and decode failures to one stable client error", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.String, + }), + ), + ) + + expect(output.operations[0]?.errors).toContain("ClientError") + expect(output.operations[0]?.errors).not.toContain("HttpClientError") + expect(output.operations[0]?.errors).not.toContain("SchemaError") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + "new ClientError({ cause: error })", + ) + }) +}) diff --git a/packages/httpapi-codegen/test/generated-consumer.ts b/packages/httpapi-codegen/test/generated-consumer.ts new file mode 100644 index 00000000000..448db01be82 --- /dev/null +++ b/packages/httpapi-codegen/test/generated-consumer.ts @@ -0,0 +1,28 @@ +import { Effect, Stream } from "effect" +import { HttpClient } from "effect/unstable/http" +import { ClientError, OpenCode } from "./generated" +import { Missing } from "./fixture" + +export const program = OpenCode.make().pipe( + Effect.map((client) => { + const health = client.session.health() + const list = client.session.list() + const filtered = client.session.list({ archived: true }) + const get = client.session.get({ sessionID: "session" }) + const interrupt = client.session.interrupt({ sessionID: "session" }) + const status = client.status() + const subscribe = client.event.subscribe() + + const _health: Effect.Effect = health + const _list: Effect.Effect, ClientError> = list + const _filtered: Effect.Effect, ClientError> = filtered + const _get: Effect.Effect = get + const _interrupt: Effect.Effect = interrupt + const _status: Effect.Effect = status + const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe + + return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe } + }), +) + +const _requiresHttpClient: Effect.Effect = program diff --git a/packages/httpapi-codegen/test/generated/client-error.ts b/packages/httpapi-codegen/test/generated/client-error.ts new file mode 100644 index 00000000000..bcc65d9bdd2 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/httpapi-codegen/test/generated/client.ts b/packages/httpapi-codegen/test/generated/client.ts new file mode 100644 index 00000000000..f67fc00a99a --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client.ts @@ -0,0 +1,16 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect } from "effect" +import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" +import { adaptGroup0, Group0 } from "./session" +import { adaptGroup1, Group1 } from "./event" +import { adaptGroup2, Group2 } from "./system" + +const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2) +const adaptClient = (raw: HttpApiClient.ForApi) => ({ + session: adaptGroup0(raw["session"]), + event: adaptGroup1(raw["event"]), + ...adaptGroup2({ status: raw["status"] }), +}) + +export const make = (options?: { readonly baseUrl?: URL | string }) => + HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) diff --git a/packages/httpapi-codegen/test/generated/event.ts b/packages/httpapi-codegen/test/generated/event.ts new file mode 100644 index 00000000000..764f2fd1c70 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/event.ts @@ -0,0 +1,39 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema, Stream } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0SuccessData = Schema.Struct({ type: Schema.String }) + +const Endpoint0SuccessError = Schema.Never + +export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add( + HttpApiEndpoint.make("GET")("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ + data: Endpoint0SuccessData, + error: Endpoint0SuccessError, + contentType: "text/event-stream", + }).pipe(HttpApiSchema.status(202)), + }), +) + +type RawGroup = HttpApiClient.Client.Group + +const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError]) +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => + Stream.unwrap( + raw["subscribe"]({}).pipe( + Effect.mapError(mapEndpoint0Error), + Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))), + ), + ) + +export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) }) diff --git a/packages/httpapi-codegen/test/generated/index.ts b/packages/httpapi-codegen/test/generated/index.ts new file mode 100644 index 00000000000..bc0dbc9fa4d --- /dev/null +++ b/packages/httpapi-codegen/test/generated/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/httpapi-codegen/test/generated/session.ts b/packages/httpapi-codegen/test/generated/session.ts new file mode 100644 index 00000000000..6a1937c49e2 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/session.ts @@ -0,0 +1,96 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0Success = Schema.String + +const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) }) + +const Endpoint1Success = Schema.Array(Schema.String) + +const Endpoint2Params = Schema.Struct({ sessionID: Schema.String }) + +const Endpoint2Success = Schema.Struct({ data: Schema.String }) + +class Endpoint2Error0Class extends Schema.TaggedErrorClass("Missing")("Missing", { + message: Schema.String, +}) {} +const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 }) + +const Endpoint3Params = Schema.Struct({ sessionID: Schema.String }) + +const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 }) + +export const Group0 = HttpApiGroup.make("session", { topLevel: false }) + .add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success })) + .add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success })) + .add( + HttpApiEndpoint.make("GET")("get", "/session/:sessionID", { + params: Endpoint2Params, + success: Endpoint2Success, + error: Endpoint2Error0, + }), + ) + .add( + HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", { + params: Endpoint3Params, + success: Endpoint3Success, + }), + ) + +type RawGroup = HttpApiClient.Client.Group + +const Endpoint0DeclaredError = Schema.Never +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error)) + +type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] } +const Endpoint1DeclaredError = Schema.Never +const mapEndpoint1Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint1DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) => + raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error)) + +type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] } +const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0]) +const mapEndpoint2Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint2DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) => + raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapEndpoint2Error), + Effect.map((value) => value.data), + ) + +type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] } +const Endpoint3DeclaredError = Schema.Never +const mapEndpoint3Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint3DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) => + raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error)) + +export const adaptGroup0 = (raw: RawGroup) => ({ + health: Endpoint0(raw), + list: Endpoint1(raw), + get: Endpoint2(raw), + interrupt: Endpoint3(raw), +}) diff --git a/packages/httpapi-codegen/test/generated/system.ts b/packages/httpapi-codegen/test/generated/system.ts new file mode 100644 index 00000000000..6ba523d2c0e --- /dev/null +++ b/packages/httpapi-codegen/test/generated/system.ts @@ -0,0 +1,25 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0Success = Schema.String + +export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }), +) + +type RawGroup = HttpApiClient.Client + +const Endpoint0DeclaredError = Schema.Never +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error)) + +export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) }) diff --git a/packages/httpapi-codegen/test/write.test.ts b/packages/httpapi-codegen/test/write.test.ts new file mode 100644 index 00000000000..a4e6c29c58e --- /dev/null +++ b/packages/httpapi-codegen/test/write.test.ts @@ -0,0 +1,161 @@ +import { describe, expect } from "bun:test" +import { join } from "node:path" // kilocode_change - use native separators in cross-platform tests +import { Effect, FileSystem, Option } from "effect" +import { write, type Output } from "../src" +import { it } from "./effect" + +describe("HttpApiCodegen.write", () => { + it.effect("writes compiled files beneath the output directory", () => { + const writes: Array<{ readonly path: string; readonly content: string }> = [] + const output: Output = { + operations: [], + files: [{ path: "session.ts", content: "export const session = {}" }], + } + + return Effect.gen(function* () { + yield* write(output, "/generated") + + expect(writes).toEqual([ + { path: join("/generated", "session.ts"), content: "export const session = {}\n" }, // kilocode_change + { path: join("/generated", ".httpapi-codegen.json"), content: '[\n "session.ts"\n]\n' }, // kilocode_change + ]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(false), + makeDirectory: () => Effect.void, + writeFileString: (path, content) => { + writes.push({ path, content }) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("removes only stale files owned by the previous manifest", () => { + const removed: Array = [] + return write( + { + operations: [], + files: [{ path: "session.ts", content: "" }], + }, + "/generated", + ).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")), + makeDirectory: () => Effect.void, + readFileString: () => Effect.succeed('["old.ts", "session.ts"]'), + remove: (path) => { + removed.push(path) + return Effect.void + }, + writeFileString: () => Effect.void, + }), + ), + Effect.tap(() => Effect.sync(() => expect(removed).toEqual([join("/generated", "old.ts")]))), // kilocode_change + ) + }) + + it.effect("rejects unsafe and duplicate output paths before writing", () => { + const writes: Array = [] + return Effect.gen(function* () { + const error = yield* write( + { + operations: [], + files: [ + { path: "../outside.ts", content: "" }, + { path: "client.ts", content: "" }, + { path: "CLIENT.ts", content: "" }, + ], + }, + "/generated", + ).pipe(Effect.flip) + + expect(error._tag).toBe("GenerationError") + expect(writes).toEqual([]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: (path) => { + writes.push(path) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("rejects case-insensitive duplicate output paths", () => { + const writes: Array = [] + return Effect.gen(function* () { + const error = yield* write( + { + operations: [], + files: [ + { path: "client.ts", content: "" }, + { path: "CLIENT.ts", content: "" }, + ], + }, + "/generated", + ).pipe(Effect.flip) + + expect(error._tag).toBe("GenerationError") + expect(error.reason).toBe("Duplicate output path: CLIENT.ts") + expect(writes).toEqual([]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: (path) => { + writes.push(path) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("reserves the private manifest path", () => + write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))), + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + ), + ) + + it.effect("rejects existing symbolic-link output targets", () => + write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => Effect.succeed(path.endsWith("session.ts")), + makeDirectory: () => Effect.void, + stat: () => + Effect.succeed({ + type: "SymbolicLink", + mtime: Option.none(), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0), + blksize: Option.none(), + blocks: Option.none(), + }), + }), + ), + ), + ) +}) diff --git a/packages/httpapi-codegen/tsconfig.json b/packages/httpapi-codegen/tsconfig.json new file mode 100644 index 00000000000..681f85136aa --- /dev/null +++ b/packages/httpapi-codegen/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "types": ["bun"] + } +} diff --git a/packages/kilo-console/src/routes/config/SourcesRoute.tsx b/packages/kilo-console/src/routes/config/SourcesRoute.tsx index d6fb5eb0e6b..17f6e92cc15 100644 --- a/packages/kilo-console/src/routes/config/SourcesRoute.tsx +++ b/packages/kilo-console/src/routes/config/SourcesRoute.tsx @@ -14,9 +14,7 @@ export function SourcesRoute() { title="Load Order" description="Load order and editability without exposing secret values." meta={ - - {data().overlay.targets.active.writable ? data().overlay.targets.active.scope : "Read only"} - + {data().overlay.targets.active.writable ? data().overlay.targets.active.scope : "Read only"} } /> diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md index 865cfa1ac28..a07800555a4 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md @@ -165,6 +165,8 @@ MCP tools use the same permission system as built-in tools (`allow`, `ask`, `den For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#auto-approve-tools). +Connected servers can also add usage instructions to the model context and expose resources, including parameterized resource templates. See [Server instructions and resources](/docs/automate/mcp/using-in-kilo-code#server-instructions-and-resources). + ## Environment Variables Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables: diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md index 304457a8ddd..ca711b30aa3 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md @@ -513,6 +513,12 @@ After configuring an MCP server, Kilo Code will automatically detect available t Example: "Analyze the performance of my API" might use an MCP tool that tests API endpoints. +### Server instructions and resources + +When a connected MCP server provides instructions, Kilo adds them to the model context so the agent can follow the server's usage guidance. Kilo omits those instructions when every tool from that server is denied. + +Resource-capable servers also make the `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` tools available to the agent. Resource templates describe parameterized URIs; the agent fills in a template, then reads the resulting resource URI. Resource listing and reads use Kilo's normal read approval flow. + ## Troubleshooting MCP Servers {% tabs %} diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index d40b8cbf596..0047db658f8 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -127,20 +127,24 @@ Positionals: project path to start kilo in [string] Options: - --help Show help [boolean] - --version Show version number [boolean] - --port port to listen on [number] [default: 0] - --hostname hostname to listen on [string] [default: "127.0.0.1"] - --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false] - --mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"] - --cors additional domains to allow for CORS [array] [default: []] - -m, --model model to use in the format of provider/model [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] - --prompt prompt to use [string] - --agent agent to use [string] + --help Show help [boolean] + --version Show version number [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"] + --cors additional domains to allow for CORS [array] [default: []] + -m, --model model to use in the format of provider/model [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] + --prompt prompt to use [string] + --agent agent to use [string] + --auto auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false] + --mini start the minimal interactive interface [boolean] [default: false] + --no-replay disable mini session history replay on resume and after resize [boolean] + --replay-limit cap visible mini replay to the newest N messages [number] ``` ## kilo attach @@ -152,15 +156,18 @@ Positionals: url http://localhost:4096 [string] Options: - --help Show help [boolean] - --version Show version number [boolean] - --dir directory to run in [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] - -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo') [string] + --help Show help [boolean] + --version Show version number [boolean] + --dir directory to run in [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] + -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo') [string] + --mini start the minimal interactive interface [boolean] [default: false] + --no-replay disable mini session history replay on resume and after resize [boolean] + --replay-limit cap visible mini replay to the newest N messages [number] ``` ## kilo run @@ -172,32 +179,28 @@ Positionals: message message to send [string] [default: []] Options: - --help Show help [boolean] - --version Show version number [boolean] - --command the command to run, use message for args [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session before continuing (requires --continue or --session) [boolean] - --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] - --share share the session [boolean] - -m, --model model to use in the format of provider/model [string] - --agent agent to use [string] - --format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"] - -f, --file file(s) to attach to message [array] - --title title for the session (uses truncated prompt if no value provided) [string] - --attach attach to a running kilo server (e.g., http://localhost:4096) [string] - -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo') [string] - --dir directory to run in, path on remote server if attaching [string] - --port port for the local server (defaults to random port if no value provided) [number] - --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string] - --thinking show thinking blocks [boolean] - --replay replay interactive session history on resume and after resize (use --no-replay to disable) [boolean] [default: true] - --replay-limit cap visible interactive replay to the newest N messages [number] - -i, --interactive run in direct interactive split-footer mode [boolean] [default: false] - --dangerously-skip-permissions auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false] - --auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false] - --demo enable direct interactive demo slash commands; pass one as the message to run it immediately [boolean] [default: false] + --help Show help [boolean] + --version Show version number [boolean] + --command the command to run, use message for args [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session before continuing (requires --continue or --session) [boolean] + --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] + --share share the session [boolean] + -m, --model model to use in the format of provider/model [string] + --agent agent to use [string] + --format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"] + -f, --file file(s) to attach to message [array] + --title title for the session (uses truncated prompt if no value provided) [string] + --attach attach to a running kilo server (e.g., http://localhost:4096) [string] + -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo') [string] + --dir directory to run in, path on remote server if attaching [string] + --port port for the local server (defaults to random port if no value provided) [number] + --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string] + --thinking show thinking blocks [boolean] + -i, --interactive run in direct interactive split-footer mode [boolean] [default: false] + --auto auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false] ``` ## kilo debug diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index a8b1d5e9b61..9f099096e93 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -97,6 +97,8 @@ The `kilo console` command and its browser interface are deprecated and will be | `/copy` | - | Copy latest agent response | | `/copy-session` | - | Copy session transcript | | `/export` | - | Export session transcript | +| `/move` | - | Move the current session to another project directory | +| `/diff` | - | Open the diff viewer | | `/timestamps` | `/toggle-timestamps` | Show/hide timestamps | | `/thinking` | `/toggle-thinking` | Show/hide thinking blocks | @@ -219,6 +221,19 @@ There is no notification slash command or command-palette toggle. Use `tui.json` The CLI's interactive mode supports slash commands for common operations. The main commands are documented above in the [Interactive Slash Commands](#interactive-slash-commands) section. +Use `/diff` to review working-tree changes. From the diff viewer, switch the source to the current branch compared with the main branch or to changes from the last assistant turn. Use `/move` to move the current session to another project directory. + +The `diff_open` and `session_move` TUI keybindings run the same actions and are unbound by default. Set them under `keybinds` in `tui.jsonc`: + +```jsonc +{ + "keybinds": { + "diff_open": "d", + "session_move": "o", + }, +} +``` + ## Permissions Kilo Code uses the permission config to decide whether a given action should run automatically, prompt you, or be blocked. diff --git a/packages/kilo-docs/pages/customize/skills.md b/packages/kilo-docs/pages/customize/skills.md index 9f6520b370a..415e1522157 100644 --- a/packages/kilo-docs/pages/customize/skills.md +++ b/packages/kilo-docs/pages/customize/skills.md @@ -105,17 +105,20 @@ The remote server must serve an `index.json` file at the URL path with the follo ```json { "skills": [ - { "name": "skill-name", "files": ["SKILL.md", "references/file.md"] } + { "name": "skill-name", "version": "2", "files": ["SKILL.md", "references/file.md"] } ] } ``` Each skill object contains: - `name`: The skill name (must match the directory name) +- `version`: Optional version string for refreshing cached skill files - `files`: Array of files to fetch for this skill (must include `SKILL.md`) Files are downloaded from `{url}/{skill-name}/{file}` paths. +When you change a remote skill's contents or file list, also change its `version`. On the next skill rediscovery (`/reload` or a new session), Kilo downloads the complete new version before atomically replacing the cached directory. If any download fails, Kilo keeps the previous cached version. + {% /tab %} {% tab label="CLI" %} @@ -174,17 +177,20 @@ The remote server must serve an `index.json` file at the URL path with the follo ```json { "skills": [ - { "name": "skill-name", "files": ["SKILL.md", "references/file.md"] } + { "name": "skill-name", "version": "2", "files": ["SKILL.md", "references/file.md"] } ] } ``` Each skill object contains: - `name`: The skill name (must match the directory name) +- `version`: Optional version string for refreshing cached skill files - `files`: Array of files to fetch for this skill (must include `SKILL.md`) Files are downloaded from `{url}/{skill-name}/{file}` paths. +When you change a remote skill's contents or file list, also change its `version`. On the next skill rediscovery (`/reload` or a new session), Kilo downloads the complete new version before atomically replacing the cached directory. If any download fails, Kilo keeps the previous cached version. + {% /tab %} {% /tabs %} diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png index 49c95de8d94..dfee99ea3d9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a74c7f53a5d9afa743ee49d298ce8fb7e357c5fcc46cad45de353694a7540dc -size 49476 +oid sha256:1b31bb2d56b3479951297af0a931c04c3a9d68c6a0441c69280bc755d530b725 +size 49684 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png index 22ffe596c1a..82199c3bedc 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666 -size 52895 +oid sha256:670be20ffe37bc4e565d22bbe5e2becb15fe6da8a48a98a41c8f608faf3059cb +size 52936 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png index 5568f063ac2..e022dc399a9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29e79ac79e992bf7f19646aab8ba10464872f4d012abed458a2a432a223668da -size 38129 +oid sha256:c2f364bf3add1e41f97b8c8540d95c34873e435b761fa00195abcd73a23bec4e +size 38146 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png index a9c0ac26a82..47c6308f845 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7b0cb661ae199182341c0034495298cb939ed5487ed8d1dcd41bb558eda8d0e3 -size 45709 +oid sha256:cef414cf59da1f632a7d5d9a2e44f792e7e2ea6b4041bcc214cb32c052f851d8 +size 46930 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png index 22456a13149..0441c44b146 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4cf97da43aa741ac4227026dbb4f16a3a2e44a8394a4f0ab321a0938a5d5fb6e -size 32313 +oid sha256:bd700edca458baefb4ce6cea68ed78f8c002bfb6edd842dbea318c2fa443b814 +size 32789 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-unknown-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-unknown-chromium-linux.png index 00e71e588fb..aff1fa20de4 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-unknown-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/turn-outcome-unknown-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4544323cd000c4f43b88c420bbf2b7cb72317b6d40b0b0f3c690fb9deabaa27d -size 3737 +oid sha256:a9eb9fec954d92e738859717b46365e031cf89d4084c75085602d213f25a796d +size 3817 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png index 89de007ed93..da092f54d80 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:acccbf1fa9a76076f324031ed4d7e9ac317059a127c238cf9eaa9e4569dd38b2 -size 7023 +oid sha256:e854dd761ae03a44210467d2550849b1e49dd9a3ee4efef50d5116962a9a4059 +size 6907 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png index e571fdb6845..6ddfae16264 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:033960b68a915d0b05ce539784998d0f84603f206689dbbd14005771d112faa4 -size 762429 +oid sha256:70c840330c1a902194a6dd56d0dc5b875fcd66fb59f2c1c7ce926b79559ea326 +size 762172 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png index c8800620980..62a91b8a31b 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1ee158333ea6c4478f6c167eac878bdc5969fce47ba9f68a1e731fd9035e9e49 -size 52312 +oid sha256:f62003e7ebf0eb3be33e71294a0937dad2e5ee7913f540ec67ca1cec7c173505 +size 45736 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index a786a86d271..f6b84e3d290 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -205,7 +205,7 @@ - -- +- - diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 29012410381..254d66698f3 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -170,6 +170,8 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi The JetBrains plugin has two independent CLI controls. Use the commands below directly when asked to change either one; do not hand-edit versions by guesswork. +For a one-shot pin/unpin/regen that also cleans every leftover CLI binary and build artifact in the current worktree, use the `jetbrains-cli-pin` skill (`.kilo/skills/jetbrains-cli-pin/SKILL.md`): `bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts `. + **Pin mode** (`kilo.cli.pinned` in `packages/kilo-jetbrains/gradle.properties`) controls release CLI vs local repo CLI. | Ask | Do | diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index e09355e19f2..bcba0effb4c 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -26,6 +26,13 @@ val repoCli = pinned.map { !it } val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false) val downloadsCli = repoCli.zip(bundled) { repo, bundle -> !repo && !bundle } val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode") +val local = rootProject.layout.projectDirectory.file(".gradle/kilo-cli-pin.properties") +val bunPathProvider = providers.fileContents(local).asText.map { text -> + text.lineSequence().firstNotNullOfOrNull { line -> + val pair = line.split("=", limit = 2) + if (pair.getOrNull(0)?.trim() == "bun.path") pair.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() } else null + } ?: "bun" +}.orElse("bun") val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text -> Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1) @@ -64,12 +71,13 @@ val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) { ) cacheDir.set(layout.buildDirectory.dir("cli-cache")) spec.set(rawSpec) + bunPath.set(bunPathProvider) } val buildRepoCli by tasks.registering(Exec::class) { description = "Build the local repo CLI for the current platform" workingDir = repoRootDir.asFile - commandLine("bun", "run", "script/build.ts", "--single", "--skip-install") + commandLine(bunPathProvider.get(), "run", "script/build.ts", "--single", "--skip-install") } fun platform(): String { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index 9425f0779f7..580d226c33d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -126,14 +126,18 @@ class KiloBackendCliManager( private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File { val force = forceExtract forceExtract = false + val version = KiloProps.cliVersion() + val platform = KiloCliPlatform.current() if (KiloRepoCli.available()) { - if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}") + if (force) log.info("Force re-extracting bundled CLI $version") + log.info("Kilo CLI mode: BUNDLED — using CLI $version ($platform) shipped in the plugin; no download needed") val cli = KiloRepoCli.extract(force) - onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current())) + onProgress(CliDownload(100, version, platform)) return cli } - if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}") - return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress) + if (force) log.info("Force re-downloading CLI $version") + log.info("Kilo CLI mode: DOWNLOAD — resolving CLI $version ($platform) from the GitHub release") + return KiloCliDownloader(log = log).resolve(version, force, onProgress) } // Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index bee74c12bb4..11cb6754ffe 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -112,7 +112,7 @@ class KiloCliDownloader( "completeExists=${done.isFile} digestValid=$valid exe=${exe.absolutePath} complete=${done.absolutePath}" ) if (!exe.isFile || !valid) return null - log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") + log.info("Kilo CLI $version ($platform) already cached at ${exe.absolutePath}; skipping download and extraction") if (!SystemInfo.isWindows) exe.setExecutable(true) prune(version) return exe diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt index 8138ae4daed..617e805ba76 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt @@ -36,10 +36,12 @@ object KiloRepoCli { val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}") val done = File(root, ".complete") if (!force && done.isFile && exe.isFile) { + log.info("Bundled Kilo CLI ${KiloProps.cliVersion()} ($platform) already extracted at ${exe.absolutePath}; skipping extraction") if (!SystemInfo.isWindows) exe.setExecutable(true) if (cleanup) prune(root) return@withContext exe } + log.info("Extracting bundled Kilo CLI ${KiloProps.cliVersion()} ($platform) into ${root.absolutePath}") if (root.exists() && !root.deleteRecursively()) { throw IllegalStateException("Failed to delete local repo CLI under ${root.absolutePath}") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 58e929dcd7e..2fe3edca360 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -11,6 +11,7 @@ import ai.kilocode.backend.app.LoadProgress import ai.kilocode.backend.app.ProfileResult import ai.kilocode.backend.cli.KiloCliPlatform import ai.kilocode.backend.cli.KiloProps +import ai.kilocode.backend.cli.KiloRepoCli import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.KiloAppRpcApi @@ -57,6 +58,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun cliPlatform(): String = KiloCliPlatform.current() + override suspend fun cliBundled(): Boolean = KiloRepoCli.available() + override suspend fun retry() = app.retry() override suspend fun restart() = app.restart() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index 8c489813a65..477f001849f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -70,7 +70,7 @@ class KiloCliDownloaderTest { assertEquals(cli.absolutePath, cached.absolutePath) assertEquals(1, server.requestCount) assertTrue(cachedProgress.isEmpty()) - assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}") + assertContains(log.messages, "INFO: Kilo CLI 1.2.3 (${KiloCliPlatform.current()}) already cached at ${cli.absolutePath}; skipping download and extraction") File(cli.parentFile.parentFile, ".complete").writeText("ok\n") server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index 5482dfbd492..1c1181c34d1 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -49,6 +49,9 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { @get:Internal abstract val cacheDir: DirectoryProperty + @get:Internal + abstract val bunPath: Property + @get:OutputFile abstract val spec: RegularFileProperty @@ -76,7 +79,7 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { val err = ByteArrayOutputStream() val result = exec.exec { workingDir = root - commandLine("bun", "run", "--conditions=browser", "./src/index.ts", "generate") + commandLine(bunPath.get(), "run", "--conditions=browser", "./src/index.ts", "generate") standardOutput = out errorOutput = err isIgnoreExitValue = true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt index 1bad925ec0c..6ed21bcb695 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt @@ -15,8 +15,10 @@ class CoreInfoAction : AnAction(), DumbAware { val app = service() val info = app.core if (info == null) app.fetchCoreInfoAsync() + app.fetchBundledAsync() + val key = if (app.bundled == true) "action.Kilo.CoreInfo.bundled" else "action.Kilo.CoreInfo.text" e.presentation.text = info?.let { - KiloBundle.message("action.Kilo.CoreInfo.text", it.version, it.platform) + KiloBundle.message(key, it.version, it.platform) } ?: KiloBundle.message("action.Kilo.CoreInfo.loading") e.presentation.description = KiloBundle.message("action.Kilo.CoreInfo.description") e.presentation.isEnabled = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 23443265e1b..1751383899a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -58,6 +58,18 @@ class KiloAppService internal constructor( val version: String? get() = info?.version + /** + * Whether the running Core is bundled in the plugin (true) or downloaded (false). + * Null until fetched. This is a static property of the plugin build, so it is + * fetched once via RPC independently of the download-progress state. + */ + @Volatile + private var bundledFlag: Boolean? = null + private val bundledLock = Any() + private var bundledJob: Job? = null + + val bundled: Boolean? get() = bundledFlag + /** * App-lifetime scope for fire-and-forget work that must outlive transient UIs such as the * settings dialog (whose own scope is cancelled the moment it closes on OK). @@ -152,6 +164,7 @@ class KiloAppService internal constructor( platform = call { cliPlatform() }, ) info = next + bundledFlag = call { cliBundled() } next } catch (e: Exception) { LOG.warn("core info failed", e) @@ -200,6 +213,26 @@ class KiloAppService internal constructor( fetchCoreInfoAsync { done(it?.version) } } + /** Fetch whether the running Core is bundled and cache it. Deduped and fetched once. */ + fun fetchBundledAsync() { + if (bundledFlag != null) return + synchronized(bundledLock) { + if (bundledFlag != null || bundledJob != null) return + bundledJob = cs.launch { + val value = try { + call { cliBundled() } + } catch (e: Exception) { + LOG.warn("core bundled check failed", e) + null + } + synchronized(bundledLock) { + if (value != null) bundledFlag = value + bundledJob = null + } + } + } + } + fun refreshModelFavoritesAsync() { cs.launch { try { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 0140ed4d7bf..cd2264888de 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -769,6 +769,7 @@ action.Kilo.Reinstall.text=Reinstall action.Kilo.Reinstall.cli.text=Reinstall Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.CoreInfo.text=Core v{0} • Architecture: {1} +action.Kilo.CoreInfo.bundled=Bundled Core v{0} • Architecture: {1} action.Kilo.CoreInfo.loading=Core details loading... action.Kilo.CoreInfo.description=Kilo Core version and architecture action.Kilo.Session.Open.text=Open diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 15944a645df..a3ad22de392 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=إنهاء عملية Core وإعادة تشغي action.Kilo.Reinstall.text=إعادة تثبيت Kilo action.Kilo.Reinstall.cli.text=إعادة تثبيت Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=‏Core المُضمّن v{0} • البنية: {1} action.Kilo.Session.Open.text=فتح action.Kilo.Session.Open.description=فتح الجلسة المحددة action.Kilo.Session.Rename.text=إعادة تسمية diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index df5882b5000..f865d877598 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Ugasite i ponovo pokrenite Core proces action.Kilo.Reinstall.text=Ponovo instalirajte Kilo action.Kilo.Reinstall.cli.text=Ponovo instalirajte Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Ugrađeni Core v{0} • Arhitektura: {1} action.Kilo.Session.Open.text=Otvori action.Kilo.Session.Open.description=Otvorite odabranu sesiju action.Kilo.Session.Rename.text=Preimenuj diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 65d9d0909cd..aa558608cf1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Afslut og genstart Core-processen action.Kilo.Reinstall.text=Geninstaller Kilo action.Kilo.Reinstall.cli.text=Geninstaller Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Medfølgende Core v{0} • Arkitektur: {1} action.Kilo.Session.Open.text=Åbn action.Kilo.Session.Open.description=Åbn den valgte session action.Kilo.Session.Rename.text=Omdøb diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 9b0ca266323..0aaaa9b1063 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Core-Prozess beenden und neu starten action.Kilo.Reinstall.text=Kilo neu installieren action.Kilo.Reinstall.cli.text=Core neu installieren action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Gebündelter Core v{0} • Architektur: {1} action.Kilo.Session.Open.text=Öffnen action.Kilo.Session.Open.description=Ausgewählte Sitzung öffnen action.Kilo.Session.Rename.text=Umbenennen diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 0d19e12cfd9..9ad869fc591 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Terminar y reiniciar el proceso Core action.Kilo.Reinstall.text=Reinstalar Kilo action.Kilo.Reinstall.cli.text=Reinstalar Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core incluido v{0} • Arquitectura: {1} action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir la sesión seleccionada action.Kilo.Session.Rename.text=Renombrar diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 683bf55d439..dfe14978821 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Tuer et redémarrer le processus Core action.Kilo.Reinstall.text=Réinstaller Kilo action.Kilo.Reinstall.cli.text=Réinstaller Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core intégré v{0} • Architecture : {1} action.Kilo.Session.Open.text=Ouvrir action.Kilo.Session.Open.description=Ouvrir la session sélectionnée action.Kilo.Session.Rename.text=Renommer diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index e9e1c52f3cb..1c8e84104d9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Coreプロセスを終了して再起動 action.Kilo.Reinstall.text=Kiloを再インストール action.Kilo.Reinstall.cli.text=Coreを再インストール action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=バンドルされた Core v{0} • アーキテクチャ: {1} action.Kilo.Session.Open.text=開く action.Kilo.Session.Open.description=選択したセッションを開く action.Kilo.Session.Rename.text=変名 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index c2e935a4ed7..5594fdf3199 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Core 프로세스 종료 후 재시작 action.Kilo.Reinstall.text=Kilo 재설치 action.Kilo.Reinstall.cli.text=Core 재설치 action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=번들 Core v{0} • 아키텍처: {1} action.Kilo.Session.Open.text=열기 action.Kilo.Session.Open.description=선택한 세션 열기 action.Kilo.Session.Rename.text=이름 바꾸기 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index e43ba741bd8..d96e7241085 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Core-proces beëindigen en herstarten action.Kilo.Reinstall.text=Kilo herinstalleren action.Kilo.Reinstall.cli.text=Core herinstalleren action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Gebundelde Core v{0} • Architectuur: {1} action.Kilo.Session.Open.text=Openen action.Kilo.Session.Open.description=Geselecteerde sessie openen action.Kilo.Session.Rename.text=Hernoemen diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index d391bcc32a3..57502a00bea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Avslutt og start Core-prosessen på nytt action.Kilo.Reinstall.text=Installer Kilo på nytt action.Kilo.Reinstall.cli.text=Installer Core på nytt action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Innebygd Core v{0} • Arkitektur: {1} action.Kilo.Session.Open.text=Åpne action.Kilo.Session.Open.description=Åpne valgt økt action.Kilo.Session.Rename.text=Gi nytt navn diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index ef4c5efe137..4b9bf11f462 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Zakończ i uruchom ponownie proces Core action.Kilo.Reinstall.text=Zainstaluj ponownie Kilo action.Kilo.Reinstall.cli.text=Zainstaluj ponownie Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Wbudowany Core v{0} • Architektura: {1} action.Kilo.Session.Open.text=Otwórz action.Kilo.Session.Open.description=Otwórz wybraną sesję action.Kilo.Session.Rename.text=Zmień nazwę diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 2bf9c4c4cce..08de02c275d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Encerrar e reiniciar o processo Core action.Kilo.Reinstall.text=Reinstalar Kilo action.Kilo.Reinstall.cli.text=Reinstalar Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core incluído v{0} • Arquitetura: {1} action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir a sessão selecionada action.Kilo.Session.Rename.text=Renomear diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index aef654244b3..90a13ed0ed9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Завершить и перезапустить action.Kilo.Reinstall.text=Переустановить Kilo action.Kilo.Reinstall.cli.text=Переустановить Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Встроенный Core v{0} • Архитектура: {1} action.Kilo.Session.Open.text=Открыть action.Kilo.Session.Open.description=Открыть выбранную сессию action.Kilo.Session.Rename.text=Переименовать diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 8959598aa7a..ee10c59c9aa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=สิ้นสุดและรีสตาร action.Kilo.Reinstall.text=ติดตั้ง Kilo ใหม่ action.Kilo.Reinstall.cli.text=ติดตั้ง Core ใหม่ action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core แบบรวมมาในตัว v{0} • สถาปัตยกรรม: {1} action.Kilo.Session.Open.text=เปิด action.Kilo.Session.Open.description=เปิดเซสชันที่เลือก action.Kilo.Session.Rename.text=เปลี่ยนชื่อ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index b954188a5ca..15c992fac3f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Core sürecini sonlandır ve yeniden başlat action.Kilo.Reinstall.text=Kilo’yu yeniden yükle action.Kilo.Reinstall.cli.text=Core’yi yeniden yükle action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Paketlenmiş Core v{0} • Mimari: {1} action.Kilo.Session.Open.text=Aç action.Kilo.Session.Open.description=Seçilen oturumu aç action.Kilo.Session.Rename.text=Yeniden adlandır diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 77ea64c7752..b69ddc88618 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Завершити і перезапустити action.Kilo.Reinstall.text=Перевстановити Kilo action.Kilo.Reinstall.cli.text=Перевстановити Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Вбудований Core v{0} • Архітектура: {1} action.Kilo.Session.Open.text=Відкрити action.Kilo.Session.Open.description=Відкрити вибрану сесію action.Kilo.Session.Rename.text=Перейменувати diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 909073cae3e..f16bb2266b8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=终止并重启 Core 进程 action.Kilo.Reinstall.text=重新安装 Kilo action.Kilo.Reinstall.cli.text=重新安装 Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=捆绑的 Core v{0} • 架构:{1} action.Kilo.Session.Open.text=打开 action.Kilo.Session.Open.description=打开所选会话 action.Kilo.Session.Rename.text=重命名 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index bc74d81c93a..6eec893d1af 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=終止並重啟 Core 程序 action.Kilo.Reinstall.text=重新安裝 Kilo action.Kilo.Reinstall.cli.text=重新安裝 Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=捆綁的 Core v{0} • 架構:{1} action.Kilo.Session.Open.text=開啟 action.Kilo.Session.Open.description=開啟所選工作階段 action.Kilo.Session.Rename.text=重命名 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index 477a78333ca..9d4689d514b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -126,6 +126,21 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertEquals("Core v1.2.3 • Architecture: darwin-arm64", event.presentation.text) } + fun `test core info action marks bundled core`() { + appRpc.cliVersion = "1.2.3" + appRpc.cliPlatform = "darwin-arm64" + appRpc.cliBundled = true + ApplicationManager.getApplication().executeOnPooledThread { + runBlocking { app().coreInfo() } + }.get() + val action = CoreInfoAction() + val event = event(action) + + update(action, event) + + assertEquals("Bundled Core v1.2.3 • Architecture: darwin-arm64", event.presentation.text) + } + fun `test local config action says open when target exists`() { rpc.localConfigPath = "/test/.kilo/kilo.jsonc" rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 7c7d4a9affe..ba9ce1d3bca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -37,6 +37,7 @@ class FakeAppRpcApi : KiloAppRpcApi { var health = HealthDto(healthy = true, version = "1.0.0") var cliVersion = "1.0.0" var cliPlatform = "darwin-arm64" + var cliBundled = false var cliInfoGate: CompletableDeferred? = null var cliInfoError: Exception? = null var cliVersionCalls = 0 @@ -94,6 +95,11 @@ class FakeAppRpcApi : KiloAppRpcApi { return cliPlatform } + override suspend fun cliBundled(): Boolean { + assertNotEdt("cliBundled") + return cliBundled + } + override suspend fun retry() { assertNotEdt("retry") retries += 1 diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index e05b4c3bdf1..2322ce61640 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -45,6 +45,9 @@ interface KiloAppRpcApi : RemoteApi { /** Core platform downloaded by the backend process. */ suspend fun cliPlatform(): String + /** Whether the running Core is bundled in the plugin (true) or downloaded (false). */ + suspend fun cliBundled(): Boolean + /** Retry app connection or loading after a failure. */ suspend fun retry() diff --git a/packages/kilo-memory/test/capture.test.ts b/packages/kilo-memory/test/capture.test.ts index eba37bc79dd..a1dcdc609c2 100644 --- a/packages/kilo-memory/test/capture.test.ts +++ b/packages/kilo-memory/test/capture.test.ts @@ -393,7 +393,13 @@ describe("memory capture parsing", () => { }, { name: "expected skip: short edited turn is interval-gated instead of trivial", - input: { ...base, summary: "User: test Result: ok", edited: true, priorTime: 900, lastTypedConsolidationAt: 900 }, + input: { + ...base, + summary: "User: test Result: ok", + edited: true, + priorTime: 900, + lastTypedConsolidationAt: 900, + }, expected: { digestDue: false, typedCall: false, fallbackDigest: false, skipReason: "interval" }, }, { @@ -416,7 +422,15 @@ describe("memory capture parsing", () => { // Identical churn yields the identical verdict across every kind of path, so no ecosystem // (manifest, doc, config, or source language) is treated specially. - for (const file of ["src/app.ts", "src/app.py", "src/app.go", "src/app.rb", "package.json", "docs/x.md", "config.yaml"]) { + for (const file of [ + "src/app.ts", + "src/app.py", + "src/app.go", + "src/app.rb", + "package.json", + "docs/x.md", + "config.yaml", + ]) { expect(hasSubstantialDiff([{ file, additions: 1, deletions: 0 }]), `${file} small`).toBe(false) expect(hasSubstantialDiff([{ file, additions: 20, deletions: 0 }]), `${file} large`).toBe(true) } @@ -495,7 +509,13 @@ describe("memory capture parsing", () => { test("recognizes an exact-key upsert even when the model's key needs slugging", () => { const items = [ { - id: MemoryOperations.id({ action: "add", file: "project.md", section: "Facts", key: "Deploy Target", text: "" }), + id: MemoryOperations.id({ + action: "add", + file: "project.md", + section: "Facts", + key: "Deploy Target", + text: "", + }), file: "project.md" as const, section: "Facts", key: "deploy_target", @@ -506,7 +526,13 @@ describe("memory capture parsing", () => { items, skipped: [], ops: [ - { action: "add", file: "project.md", section: "Facts", key: "Deploy Target", text: "Deploy to production now." }, + { + action: "add", + file: "project.md", + section: "Facts", + key: "Deploy Target", + text: "Deploy to production now.", + }, ], }) @@ -764,9 +790,9 @@ describe("memory capture parsing", () => { expect(MemoryOperations.reject({ text: "Fixed the timeout bug\nthe retry path was reviewed." })).toBeUndefined() // A statement whose subject IS the source file is provenance... - expect(MemoryOperations.reject({ text: "~/.claude/CLAUDE.md is user-level context for concise replies." })).toMatchObject( - { reason: "out_of_scope" }, - ) + expect( + MemoryOperations.reject({ text: "~/.claude/CLAUDE.md is user-level context for concise replies." }), + ).toMatchObject({ reason: "out_of_scope" }) // ...but a fact that merely cites the file mid-sentence is kept. expect( MemoryOperations.reject({ text: "Run tests from packages/opencode per the rule in .claude/claude.md." }), diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 069783c53fc..ebfe467e076 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -240,7 +240,7 @@ export class AgentManagerProvider implements Disposable { await this.stateReady return this.state }, - stats: (refresh) => this.statsPoller.snapshot(refresh), + stats: () => this.statsPoller.snapshot(), prs: () => this.prBridge.snapshot(), push: () => this.pushState(), managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id), @@ -1382,6 +1382,7 @@ export class AgentManagerProvider implements Disposable { reviewDiffStyle: state.getReviewDiffStyle(), reviewMarkdownRender: getDiffMarkdownRender(), terminalDestination: this.destination.value(), + terminalFont: readTerminalFont(), isGitRepo: true, defaultBaseBranch: state.getDefaultBaseBranch(), activeTarget: state.getActiveTarget(), @@ -1408,6 +1409,7 @@ export class AgentManagerProvider implements Disposable { reviewDiffStyle: "unified", reviewMarkdownRender: getDiffMarkdownRender(), terminalDestination: this.destination.value(), + terminalFont: readTerminalFont(), isGitRepo: false, runStatuses: [], runScriptConfigured: false, diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 9b5fd45ef5a..188885497f0 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -131,13 +131,7 @@ export class GitStatsPoller { this.lastStats = {} } - async snapshot(refresh = false): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> { - if (refresh && !this.busy) { - this.busy = true - await Promise.all([this.fetchWorktreeStats(true), this.fetchLocalStats()]).finally(() => { - this.busy = false - }) - } + async snapshot(): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> { return { worktrees: Object.values(this.lastStats), ...(this.lastLocalStats ? { local: this.lastLocalStats } : {}), @@ -170,10 +164,10 @@ export class GitStatsPoller { } private async fetch(generation = this.generation): Promise { - await Promise.all([this.fetchWorktreeStats(false, generation), this.fetchLocalStats(generation)]) + await Promise.all([this.fetchWorktreeStats(generation), this.fetchLocalStats(generation)]) } - private async fetchWorktreeStats(includeSkipped = false, generation = this.generation): Promise { + private async fetchWorktreeStats(generation = this.generation): Promise { const worktrees = this.options.getWorktrees() if (worktrees.length === 0) return @@ -189,7 +183,7 @@ export class GitStatsPoller { for (const id of Object.keys(this.lastStats)) { if (!ids.has(id)) delete this.lastStats[id] } - const active = includeSkipped ? available : available.filter((wt) => !this.skipWorktreeIds.has(wt.id)) + const active = available.filter((wt) => !this.skipWorktreeIds.has(wt.id)) if (active.length === 0) { if (available.length > 0) return if (this.lastHash === "") return diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts index cb2fa2da09d..e36948c449a 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts @@ -44,7 +44,7 @@ interface Options { root(): string | undefined ready(): Promise state(): WorktreeStateManager | undefined - stats(refresh?: boolean): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> + stats(): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> prs(): Map push(): void managed(sessionID: string): boolean @@ -257,7 +257,10 @@ export class AgentManagerOrchestrationBridge { if (this.disposed || active.cancelled) return const client = this.connection.getClient() if (request.operation === "overview") { - const stats = await this.options.stats(true) + // Git stats are refreshed by the poller independently. A forced refresh + // here can spawn one diff/ahead-behind pair per worktree and exceed the + // host request timeout before the overview can return its IDs. + const stats = await this.options.stats() if (this.disposed || active.cancelled) return const result = await overview({ client, diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index bcdef484dc2..24f916542e5 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -46,15 +46,6 @@ interface Entry { title: string } -/** Stable prefix used for terminal tab IDs in the webview (e.g. `terminal:abc123`). */ -export const TERMINAL_PREFIX = "terminal:" - -/** Generate a reasonably unique terminal ID without bringing in a uuid dep. */ -function makeTerminalId(): string { - const rand = Math.random().toString(36).slice(2, 8) - return `${TERMINAL_PREFIX}${Date.now().toString(36)}-${rand}` -} - export class TerminalManager { private readonly entries = new Map() private readonly restarts = new Map>() @@ -70,6 +61,7 @@ export class TerminalManager { * tab back into the correct sidebar context. */ async create(params: { + terminalId: string worktreeId: string | null cwd: string title: string @@ -84,18 +76,17 @@ export class TerminalManager { const err = error instanceof Error ? error.message : String(error ?? "unknown error") throw new Error(`Failed to create PTY: ${err}`) } - const terminalId = makeTerminalId() const entry: Entry = { - terminalId, + terminalId: params.terminalId, ptyID: data.id, worktreeId: params.worktreeId, cwd: params.cwd, title: data.title ?? params.title, } - this.entries.set(terminalId, entry) + this.entries.set(params.terminalId, entry) const wsUrl = this.deps.buildWsUrl(entry.ptyID, entry.cwd) - this.deps.log(`Terminal created: ${terminalId} -> pty ${entry.ptyID} cwd=${entry.cwd}`) - return { terminalId, worktreeId: entry.worktreeId, title: entry.title, wsUrl } + this.deps.log(`Terminal created: ${params.terminalId} -> pty ${entry.ptyID} cwd=${entry.cwd}`) + return { terminalId: params.terminalId, worktreeId: entry.worktreeId, title: entry.title, wsUrl } } /** Forward a resize event to the backend PTY. Missing terminals are a no-op. */ diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index b58e25b8a9f..95fe5c86328 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -155,7 +155,7 @@ export class TerminalRouter { // Join the shared backend connection instead of racing its synchronous // client accessor when this is the first Kilo action in the window. await this.deps.getClientAsync() - const created = await manager.create({ worktreeId, cwd, title }) + const created = await manager.create({ terminalId: createId, worktreeId, cwd, title }) if (generation !== this.generation) { await manager.close(created.terminalId) return diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 5f08325a9fe..76389d4f288 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -147,6 +147,7 @@ interface StateMessage { /** Last selected sidebar target for seamless project-switch restore. */ activeTarget?: SidebarTarget terminalDestination?: TerminalDestination + terminalFont?: TerminalFont } /** Project catalog pushed to the webview after registry or context changes. */ @@ -939,7 +940,7 @@ interface MoveSectionIn { interface TerminalCreateIn { type: "agentManager.terminal.create" - /** Webview-generated correlation id, echoed back in created/error. */ + /** Webview-generated logical terminal id, echoed back in created/error. */ createId: string placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts index 2c3f845e349..dded9b2ed5d 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts @@ -37,6 +37,7 @@ describe("AgentManagerOrchestrationBridge", () => { const replies: unknown[] = [] const rejections: unknown[] = [] const lists = new Map() + const statsCalls: number[] = [] const handlers: { event?: (event: SSEPayload, directory?: string) => void state?: (state: "connecting" | "connected" | "disconnected" | "error") => void @@ -54,6 +55,12 @@ describe("AgentManagerOrchestrationBridge", () => { status: mock(async () => ({ data: {} })), promptAsync, }, + permission: { + list: mock(async () => ({ data: [] })), + }, + question: { + list: mock(async () => ({ data: [] })), + }, kilocode: { agentManager: { list: mock(async ({ directory }: { directory?: string }) => { @@ -96,7 +103,10 @@ describe("AgentManagerOrchestrationBridge", () => { root: () => root, ready: async () => state, state: () => state, - stats: async () => ({ worktrees: [] }), + stats: async () => { + statsCalls.push(1) + return { worktrees: [] } + }, prs: () => new Map(), push, managed: (id) => managed.has(id), @@ -108,7 +118,21 @@ describe("AgentManagerOrchestrationBridge", () => { { id: `event-${value.id}`, type: "kilocode.agent_manager.requested", properties: value } as SSEPayload, directory, ) - return { bridge, client, close, handlers, lists, managed, promptAsync, push, rejections, replies, request, status } + return { + bridge, + client, + close, + handlers, + lists, + managed, + promptAsync, + push, + rejections, + replies, + request, + statsCalls, + status, + } } const request: AgentManagerRequest = { @@ -233,6 +257,30 @@ describe("AgentManagerOrchestrationBridge", () => { test.bridge.dispose() }) + it("returns an overview with cached git stats", async () => { + const test = harness() + test.request({ + id: "amr_overview", + sessionID: "ses_caller", + operation: "overview", + }) + await waitFor(() => test.replies.length === 1) + + expect(test.statsCalls).toEqual([1]) + expect(test.replies[0]).toEqual({ + requestID: "amr_overview", + directory: root, + result: { + operation: "overview", + overview: expect.objectContaining({ + ungrouped: [expect.objectContaining({ id: expect.any(String) })], + sections: [], + }), + }, + }) + test.bridge.dispose() + }) + it("stops a live panel session before it is persisted", async () => { const test = harness() test.managed.add("ses_live") diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts new file mode 100644 index 00000000000..2ab5221ae27 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "bun:test" +import { createInputBuffer, createReplayGate } from "../../webview-ui/agent-manager/terminal/replay" + +describe("Agent Manager terminal input buffer", () => { + it("sends parser replies first while preserving user input order", () => { + const input = createInputBuffer() + input.add("early ") + input.add("reply", true) + input.add("command\r") + + expect(input.take()).toBe("replyearly command\r") + expect(input.take()).toBe("") + }) + + it("caps user input and protocol replies independently", () => { + const input = createInputBuffer(4) + input.add("12345") + input.add("abcde", true) + + expect(input.take()).toBe("bcde2345") + }) +}) + +describe("Agent Manager terminal replay gate", () => { + it("flushes initial input only after replay parsing completes", () => { + const events: string[] = [] + let complete: (() => void) | undefined + const gate = createReplayGate({ + write: (data, callback) => { + events.push(typeof data === "string" ? data : `bytes:${data.join(",")}`) + if (callback) complete = callback + }, + flush: () => events.push("flush"), + }) + + gate.attach(false) + expect(gate.blocked()).toBe(true) + gate.output("replay") + gate.output(new Uint8Array([1, 2, 3])) + expect(events).toEqual([]) + expect(gate.frame(new Uint8Array([0, 123, 125]))).toBe(true) + expect(gate.blocked()).toBe(true) + expect(gate.draining()).toBe(true) + expect(events).toEqual(["replay", "bytes:1,2,3", ""]) + + complete?.() + expect(gate.blocked()).toBe(false) + expect(gate.draining()).toBe(false) + expect(events).toEqual(["replay", "bytes:1,2,3", "", "flush"]) + }) + + it("leaves reconnect input on the output-settle path", () => { + const events: string[] = [] + const gate = createReplayGate({ + write: () => events.push("write"), + flush: () => events.push("flush"), + }) + + gate.attach(true) + expect(gate.blocked()).toBe(false) + expect(gate.draining()).toBe(false) + gate.output("live") + expect(gate.frame(new Uint8Array([0]))).toBe(true) + expect(events).toEqual(["write"]) + }) + + it("consumes only one initial replay boundary", () => { + let drains = 0 + const gate = createReplayGate({ + write: (_data, callback) => { + if (callback) drains++ + }, + flush: () => undefined, + }) + + gate.attach(false) + expect(gate.frame(new Uint8Array())).toBe(false) + expect(gate.frame(new Uint8Array([0]))).toBe(true) + expect(gate.frame(new Uint8Array([0]))).toBe(true) + expect(drains).toBe(1) + }) + + it("ignores an initial parse callback after reconnect starts", () => { + let complete: (() => void) | undefined + let flushed = 0 + const gate = createReplayGate({ + write: (_data, callback) => { + if (callback) complete = callback + }, + flush: () => flushed++, + }) + + gate.attach(false) + gate.frame(new Uint8Array([0])) + gate.attach(true) + complete?.() + + expect(gate.blocked()).toBe(false) + expect(flushed).toBe(0) + }) + + it("lets terminal replies pass while queued replay parses before user input flushes", () => { + const events: string[] = [] + let complete: (() => void) | undefined + const gate = createReplayGate({ + write: (data, callback) => { + events.push(String(data)) + if (callback) complete = callback + }, + flush: () => events.push("flush"), + }) + + gate.attach(false) + expect(gate.blocked()).toBe(true) + gate.output("replay") + gate.frame(new Uint8Array([0])) + expect(gate.blocked()).toBe(true) + expect(gate.draining()).toBe(true) + gate.output("terminal-reply") + expect(events).toEqual(["replay", "", "terminal-reply"]) + expect(complete).toBeFunction() + complete?.() + expect(gate.blocked()).toBe(false) + expect(gate.draining()).toBe(false) + expect(events).toEqual(["replay", "", "terminal-reply", "flush"]) + }) + + it("keeps user input blocked for the complete parser-drain window", () => { + let complete: (() => void) | undefined + const gate = createReplayGate({ + write: (_data, callback) => { + if (callback) complete = callback + }, + flush: () => undefined, + }) + + gate.attach(false) + gate.frame(new Uint8Array([0])) + expect(gate.blocked()).toBe(true) + expect(gate.draining()).toBe(true) + + complete?.() + expect(gate.blocked()).toBe(false) + expect(gate.draining()).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts index 78672282041..348a632a48e 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -41,6 +41,7 @@ describe("Agent Manager terminal routing", () => { expect(messages[0]).toMatchObject({ type: "agentManager.terminal.created", createId: "side-1", + terminalId: "side-1", placement: "side", worktreeId: "wt-1", projectId: "prj-1", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts index 92b0efd7c1f..ea0fc92ca79 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -22,7 +22,6 @@ function scene(initial: string | null = LOCAL) { shown: [] as string[], errors: 0, running: [] as Array<{ contextKey: string; terminalId: string }>, - sideFocus: [] as boolean[], } const tabs = () => state.current().map((term) => term.id) const handlers = createTerminalHandlers({ @@ -39,6 +38,7 @@ function scene(initial: string | null = LOCAL) { getSelection: selection, LOCAL, REVIEW_TAB_ID: "review", + getFont: () => font, }) const dispatch = createTerminalMessageHandler({ state, @@ -50,7 +50,6 @@ function scene(initial: string | null = LOCAL) { }, showError: () => events.errors++, postMessage: (message) => posted.push(message as Record), - onSideCreated: (_contextKey, _terminalId, focus) => events.sideFocus.push(focus), onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }), }) return { state, selection, setSelection, posted, events, handlers, dispatch } @@ -317,18 +316,23 @@ describe("Agent Manager terminal state", () => { item.handlers.requestSide() expect(item.posted).toHaveLength(1) + expect(item.state.sidesForContext(LOCAL)).toHaveLength(1) const request = item.posted[0]! expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null }) const createId = String(request.createId) - expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true) - expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") + expect(createId).toStartWith("terminal:") + const optimistic = item.state.sidesForContext(LOCAL)[0] + expect(item.dispatch(createdSide(createId, createId))).toBe(true) + expect(item.state.sidesForContext(LOCAL)[0]).toBe(optimistic) + expect(optimistic?.wsUrl).toBe(`ws://${createId}`) + expect(item.state.sideActiveFor(LOCAL)).toBe(createId) expect(item.events.activated).toEqual([]) expect(item.events.selected).toEqual([]) expect(item.events.saved).toBe(0) item.handlers.requestSide() expect(item.posted).toHaveLength(1) - expect(item.state.focusRequest()?.id).toBe("terminal:side") + expect(item.state.focusRequest()?.id).toBe(createId) dispose() }) }) @@ -347,8 +351,8 @@ describe("Agent Manager terminal state", () => { worktreeId: "wt-1", }) const createId = String(item.posted[0]!.createId) - expect(item.dispatch(createdSide(createId, "terminal:side", "Terminal 1", "wt-1"))).toBe(true) - expect(item.events.sideFocus).toEqual([false]) + expect(item.dispatch(createdSide(createId, createId, "Terminal 1", "wt-1"))).toBe(true) + expect(item.state.sidesForContext("wt-1")[0]).toMatchObject({ id: createId, title: "Terminal 1" }) dispose() }) }) @@ -358,8 +362,8 @@ describe("Agent Manager terminal state", () => { const item = scene() item.handlers.addSide() const createId = String(item.posted[0]!.createId) - expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true) - expect(item.events.sideFocus).toEqual([true]) + expect(item.dispatch(createdSide(createId, createId))).toBe(true) + expect(item.state.focusRequest()?.id).toBe(createId) dispose() }) }) @@ -373,13 +377,12 @@ describe("Agent Manager terminal state", () => { const first = String(item.posted[0]!.createId) const second = String(item.posted[1]!.createId) - item.dispatch(createdSide(first, "terminal:one", "Terminal 1")) - expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one"]) - expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + item.dispatch(createdSide(first, first, "Terminal 1")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([first, second]) - item.dispatch(createdSide(second, "terminal:two", "Terminal 2")) - expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one", "terminal:two"]) - expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two") + item.dispatch(createdSide(second, second, "Terminal 2")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([first, second]) + expect(item.state.sideActiveFor(LOCAL)).toBe(second) dispose() }) }) @@ -606,6 +609,7 @@ describe("Agent Manager terminal state", () => { getSelection: selection, LOCAL, REVIEW_TAB_ID: "review", + getFont: () => font, }) const dispatch = createTerminalMessageHandler({ state, @@ -627,12 +631,11 @@ describe("Agent Manager terminal state", () => { expect(item.posted).toHaveLength(1) const request = item.posted[0]! expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null }) - expect(item.dispatch({ ...createdSide(String(request.createId), "terminal:side"), projectId: "prj-1" })).toBe( - true, - ) + const id = String(request.createId) + expect(item.dispatch({ ...createdSide(id, id), projectId: "prj-1" })).toBe(true) expect(item.state.sideKey()).toBe("prj-1:local") - expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"]) - expect(item.state.sideActiveFor("prj-1:local")).toBe("terminal:side") + expect(item.state.sides().map((term) => term.id)).toEqual([id]) + expect(item.state.sideActiveFor("prj-1:local")).toBe(id) // A worktree context sends its plain worktree id, not "prj-1:wt-1". const wt = nsScene("wt-1") diff --git a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts index 0718b5ab294..710ca744bef 100644 --- a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts +++ b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts @@ -3,7 +3,10 @@ import { createRoot } from "solid-js" import { useSlashCommand } from "../../webview-ui/src/hooks/useSlashCommand" import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages" -function setup(sandbox: () => void, options: { enabled?: () => boolean; exclude?: () => Set } = {}) { +function setup( + sandbox: () => void, + options: { enabled?: () => boolean; exclude?: () => Set; include?: Set } = {}, +) { const sent: WebviewMessage[] = [] const handlers = new Set<(message: ExtensionMessage) => void>() const root = createRoot((dispose) => ({ @@ -18,6 +21,7 @@ function setup(sandbox: () => void, options: { enabled?: () => boolean; exclude? }, { action: sandbox, enabled: options.enabled ?? (() => true) }, options.exclude, + options.include, ), })) const fire = (message: ExtensionMessage) => { @@ -27,6 +31,33 @@ function setup(sandbox: () => void, options: { enabled?: () => boolean; exclude? } describe("useSlashCommand sandbox action", () => { + it("supports the singular model alias", () => { + const ctx = setup(() => {}) + + ctx.slash.onInput("/model", 6) + + expect(ctx.slash.results()[0]).toEqual(expect.objectContaining({ name: "models", hints: ["model"] })) + ctx.dispose() + }) + + it("can restrict the menu to worktree configuration commands", () => { + const ctx = setup(() => {}, { include: new Set(["models", "agents", "variant", "sandbox"]) }) + + ctx.fire({ + type: "commandsLoaded", + commands: [ + { name: "merge", description: "Merge changes", hints: [] }, + { name: "models", description: "Server model command", hints: [] }, + ], + }) + ctx.slash.onInput("/merge", 6) + expect(ctx.slash.results()).toEqual([]) + + ctx.slash.onInput("/models", 7) + expect(ctx.slash.results().map((command) => command.name)).toEqual(["models"]) + ctx.dispose() + }) + it("opens project memory actions from the top-level command", () => { const ctx = setup(() => {}) const state = { text: "/memory" } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index c2a0dcdfda5..a68c1099f47 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -45,7 +45,9 @@ import type { SessionCreatedMessage, BranchInfo, TerminalDestination, + TerminalFont, } from "../src/types/messages" +import { readFontSize } from "../src/font-size" import { IndexingProvider } from "../src/context/indexing" import {} from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" @@ -361,6 +363,10 @@ const AgentManagerContent: Component = () => { const PENDING_PREFIX = "pending:" const closedDrafts = new Set() const [activePendingId, setActivePendingId] = createSignal() + const [terminalFont, setTerminalFont] = createSignal({ + fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--vscode-editor-font-family").trim(), + fontSize: readFontSize(), + }) /** Namespace key so worktree/local ids from different projects never collide. */ const nsKey = (sel: string) => `${currentProjectId() ?? "single"}:${sel}` @@ -1067,6 +1073,7 @@ const AgentManagerContent: Component = () => { const applyState = (msg: ExtensionMessage) => { if (msg.type !== "agentManager.state") return const state = msg as AgentManagerStateMessage + if (state.terminalFont) setTerminalFont(state.terminalFont) const pid = state.projectId if (pid) setProjectStates((prev) => ({ ...prev, [pid]: state })) const store = pid ? registry.ensure(pid) : registry.active() @@ -1333,13 +1340,6 @@ const AgentManagerContent: Component = () => { showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }), postMessage: (message) => vscode.postMessage(message as never), onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId), - onSideCreated: (contextKey, terminalId, focus) => { - // Focus only when the user is still looking at this panel — - // a slow create landing after a mode switch must not steal it. - if (focus && sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) { - terms.requestFocus(terminalId) - } - }, onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(terminalId), onScriptRunning: (contextKey, terminalId) => { if (terms.sideKey() !== contextKey) return @@ -1358,6 +1358,7 @@ const AgentManagerContent: Component = () => { onDestinationChanged: (destination) => sideCtl.syncDefault(destination), }) const unsubTerminals = vscode.onMessage((msg) => { + if (msg.type === "agentManager.terminal.fontChanged") setTerminalFont(msg.font) terminalDispatch(msg) }) @@ -2072,6 +2073,7 @@ const AgentManagerContent: Component = () => { getSelection: selection, LOCAL, REVIEW_TAB_ID, + getFont: terminalFont, }) const sideCtl = createSideTerminal({ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 72a68c57ef4..3a3a08869b3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -43,6 +43,7 @@ import { useSpeechToTextModels } from "../src/context/speech-to-text-models" import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut" import { convertToMentionPath } from "../src/utils/path-mentions" import { insertSpacedText } from "../src/components/chat/prompt-input-utils" +import { useSlashCommand } from "../src/hooks/useSlashCommand" import { WandSparkles } from "@kilocode/kilo-ui/lucide" import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect" import { tracker } from "./telemetry" @@ -51,6 +52,8 @@ import type { ModeRouter } from "./mode-router" type VersionCount = 1 | 2 | 3 | 4 const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4] +const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox"]) +const WORKTREE_PROMPT_SCOPE = "agent-manager-worktree-prompt" type DialogTab = "new" | "import" @@ -288,6 +291,31 @@ export const NewWorktreeDialog: Component<{ let textareaRef: HTMLTextAreaElement | undefined let containerRef: HTMLDivElement | undefined + const setPromptValue = (value: string) => { + setPrompt(value) + persistPrompt(value) + adjustHeight() + } + const restorePrompt = () => { + requestAnimationFrame(() => textareaRef?.focus({ preventScroll: true })) + } + const slash = useSlashCommand( + vscode, + { action: toggleSandbox, enabled: () => sandboxVisible() && sandbox() !== undefined && sandboxAvailable() }, + () => { + const hidden = new Set() + if (session.agents().length < 2) hidden.add("agents") + if (variants().length === 0) hidden.add("variant") + if (!sandboxVisible()) hidden.add("sandbox") + return hidden + }, + WORKTREE_PROMPT_COMMANDS, + WORKTREE_PROMPT_SCOPE, + ) + const onFocusPrompt = () => restorePrompt() + window.addEventListener("focusPrompt", onFocusPrompt) + onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt)) + onMount(() => { setBranchesLoading(true) vscode.postMessage({ type: "agentManager.requestBranches", projectId: props.projectId }) @@ -389,6 +417,11 @@ export const NewWorktreeDialog: Component<{ return } + if (slash.onKeyDown(e, textareaRef, setPromptValue, restorePrompt)) { + e.stopPropagation() + return + } + // Shift+Tab cycles reasoning effort variants (setting: chat.shiftTabCyclesVariant). // When disabled or no variants exist, fall through to default focus navigation. if (e.key === "Tab" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { @@ -583,6 +616,33 @@ export const NewWorktreeDialog: Component<{ onDragLeave={imageAttach.handleDragLeave} onDrop={imageAttach.handleDrop} > + +
+ 0} + fallback={
No commands found
} + > + + {(cmd, index) => ( +
{ + e.preventDefault() + if (textareaRef) slash.select(cmd, textareaRef, setPromptValue, restorePrompt) + }} + onMouseEnter={() => slash.setIndex(index())} + > + /{cmd.name} + + {cmd.description} + +
+ )} +
+
+
+
0}>
@@ -626,6 +686,7 @@ export const NewWorktreeDialog: Component<{ setPrompt(val) persistPrompt(val) adjustHeight() + slash.onInput(val, e.currentTarget.selectionStart ?? val.length) }} onKeyDown={onKey} onKeyUp={speechUp} @@ -642,6 +703,7 @@ export const NewWorktreeDialog: Component<{ agents={session.agents()} value={agent()} onSelect={selectAgent} + trigger={WORKTREE_PROMPT_SCOPE} portal={false} deferDismiss /> @@ -652,6 +714,9 @@ export const NewWorktreeDialog: Component<{ onSelect={(pid, mid) => { if (pid && mid) setModel({ providerID: pid, modelID: mid }) }} + onPick={restorePrompt} + onCancel={restorePrompt} + trigger={WORKTREE_PROMPT_SCOPE} placement="top-start" portal={false} deferDismiss @@ -660,6 +725,7 @@ export const NewWorktreeDialog: Component<{ variants={variants()} value={effectiveVariant()} onSelect={setVariant} + trigger={WORKTREE_PROMPT_SCOPE} portal={false} deferDismiss cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 76c7c6e3b74..6cfadee1a9e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -2820,11 +2820,19 @@ body.am-wt-dragging-active * { /* While any inline (non-portaled) selector popover is open, let it escape the dialog's scroll containers. Covers model, thinking-variant, and mode pickers. */ .am-nv-dialog .am-prompt-input-container:has([data-component="popover-content"]), +.am-nv-dialog .am-prompt-input-container:has(.am-slash-command-dropdown), .am-nv-dialog-content:has([data-component="popover-content"]), -[data-component="dialog"]:has(.am-nv-dialog [data-component="popover-content"]) [data-slot="dialog-body"] { +.am-nv-dialog-content:has(.am-slash-command-dropdown), +[data-component="dialog"]:has(.am-nv-dialog [data-component="popover-content"]) [data-slot="dialog-body"], +[data-component="dialog"]:has(.am-nv-dialog .am-slash-command-dropdown) [data-slot="dialog-body"] { overflow: visible; } +.am-slash-command-dropdown { + min-width: 0; + max-width: none; +} + .am-nv-dialog .am-prompt-input-ghost-wrapper { height: 100%; } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 606c8fa47ec..8b6e2956b78 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -21,6 +21,7 @@ import { useVSCode } from "../../src/context/vscode" import { useLanguage } from "../../src/context/language" import { formatReviewCommentsMarkdown } from "../../src/utils/review-comment-markdown" import type { ScriptTerminalStatus, TerminalFont } from "./state" +import { createInputBuffer, createReplayGate } from "./replay" interface Props { terminalId: string @@ -192,7 +193,8 @@ export const TerminalTab: Component = (props) => { let ws: WebSocket | undefined let closed = false - let pending = "" + const input = createInputBuffer() + let user = false let restartRequested = false let disconnected = false let readyTimer: ReturnType | undefined @@ -234,11 +236,18 @@ export const TerminalTab: Component = (props) => { rows: term.rows, }) } + const markUser = () => { + user = true + queueMicrotask(() => { + user = false + }) + } const send = (data: string) => { - if (disconnected && props.restartable) { - pending += data - if (pending.length > 256 * 1024) pending = pending.slice(-256 * 1024) - requestRestart() + const reply = replay.draining() && !user + user = false + if (props.restartable && (replay.blocked() || disconnected || ws?.readyState !== WebSocket.OPEN)) { + input.add(data, reply) + if (disconnected) requestRestart() return } if (ws?.readyState === WebSocket.OPEN) { @@ -246,7 +255,7 @@ export const TerminalTab: Component = (props) => { return } } - const flush = () => { + const flush = (all = false) => { if (ws?.readyState !== WebSocket.OPEN) return if (readyTimer) { clearTimeout(readyTimer) @@ -256,9 +265,8 @@ export const TerminalTab: Component = (props) => { clearTimeout(fallbackTimer) fallbackTimer = undefined } - const data = pending - pending = "" - if (data && /[^\r\n]/.test(data)) ws.send(data) + const data = input.take() + if (data && (all || /[^\r\n]/.test(data))) ws.send(data) disconnected = false restartRequested = false } @@ -270,8 +278,17 @@ export const TerminalTab: Component = (props) => { flush() }, 100) } + const replay = createReplayGate({ + write: (data, callback) => term.write(data, callback), + flush: () => flush(true), + }) + const disposeKey = term.onKey(markUser) + for (const event of ["input", "paste", "compositionend", "mousedown", "wheel"]) { + host.addEventListener(event, markUser, true) + } const open = (url: string) => { if (closed || !url) return + replay.attach(disconnected) const next = new WebSocket(url) next.binaryType = "arraybuffer" ws = next @@ -289,14 +306,14 @@ export const TerminalTab: Component = (props) => { if (closed || ws !== next) return streamed = true if (typeof event.data === "string") { - term.write(event.data) + replay.output(event.data) scheduleFlush() return } if (event.data instanceof ArrayBuffer) { const bytes = new Uint8Array(event.data) - if (bytes.length > 0 && bytes[0] === 0x00) return - term.write(bytes) + if (replay.frame(bytes)) return + replay.output(bytes) scheduleFlush() } } @@ -326,6 +343,7 @@ export const TerminalTab: Component = (props) => { } } const disposeData = term.onData(send) + const disposeBinary = term.onBinary(send) open(props.wsUrl) // These addons are not needed to paint the initial prompt. Defer them @@ -440,6 +458,7 @@ export const TerminalTab: Component = (props) => { if (message.targetTerminalId !== props.terminalId) return const comments = message.comments if (!Array.isArray(comments) || comments.length === 0) return + markUser() term.paste(`${formatReviewCommentsMarkdown(comments)}\n`) return } @@ -449,6 +468,16 @@ export const TerminalTab: Component = (props) => { return } + if (message.type === "agentManager.terminal.created") { + if (message.terminalId === props.terminalId && !ws) { + term.options.fontFamily = message.font.fontFamily + term.options.fontSize = message.font.fontSize + scheduleRepaint() + open(message.wsUrl) + } + return + } + if (message.type === "agentManager.terminal.error" && message.terminalId === props.terminalId) { restartRequested = false return @@ -479,8 +508,14 @@ export const TerminalTab: Component = (props) => { createEffect(() => { const now = props.active const serial = props.focusSerial ?? 0 - if (now && (!wasActive || serial !== focusSerial)) - scheduleRepaint((serial > 0 && serial !== focusSerial) || props.focusOnActivate !== false) + if (now && (!wasActive || serial !== focusSerial)) { + const focus = (serial > 0 && serial !== focusSerial) || props.focusOnActivate !== false + // xterm creates its textarea synchronously in term.open(). Focus it + // now so a freshly revealed terminal accepts input in this event + // turn; the queued repaint below still refits and retries next frame. + if (focus && document.hasFocus()) term.focus() + scheduleRepaint(focus) + } if (!now && wasActive) term.blur() wasActive = now focusSerial = serial @@ -528,11 +563,16 @@ export const TerminalTab: Component = (props) => { window.removeEventListener("focus", onWindowFocus) host.removeEventListener("focusin", onFocusIn) host.removeEventListener("focusout", onFocusOut) + for (const event of ["input", "paste", "compositionend", "mousedown", "wheel"]) { + host.removeEventListener(event, markUser, true) + } + disposeKey.dispose() fontSub() themeObserver.disconnect() clearTimeout(resizeTimer) ro.disconnect() disposeData.dispose() + disposeBinary.dispose() disposeTitle.dispose() try { ws?.close() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts new file mode 100644 index 00000000000..f953a41ffc0 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts @@ -0,0 +1,90 @@ +interface ReplayGateDeps { + /** Write one output chunk to xterm, optionally observing parser completion. */ + write(data: string | Uint8Array, callback?: () => void): void + /** Release input buffered before the initial PTY attachment. */ + flush(): void +} + +/** Keep terminal protocol replies ahead of user input without reordering the + * user's bytes when both arrive while initial replay is being parsed. */ +export function createInputBuffer(limit = 256 * 1024) { + let input = "" + let replies = "" + + const add = (data: string, reply = false) => { + if (reply) { + replies += data + if (replies.length > limit) replies = replies.slice(-limit) + return + } + input += data + if (input.length > limit) input = input.slice(-limit) + } + + const take = () => { + const data = replies + input + replies = "" + input = "" + return data + } + + return { add, take } +} + +/** + * Gate initial user input on the PTY replay boundary. The backend sends a + * binary 0x00 metadata frame after retained output; waiting for xterm to parse + * everything queued before that frame keeps shell capability replies ahead of + * the command the user typed while the PTY was starting. + * + * Reconnects keep their existing output-settle timer instead. Their buffered + * input belongs to an exited shell recovery flow, not the initial attachment. + */ +export function createReplayGate(deps: ReplayGateDeps) { + let blocked = false + let boundary = false + let draining = false + let serial = 0 + let pending: Array = [] + + const attach = (reconnecting: boolean) => { + serial++ + blocked = !reconnecting + boundary = false + draining = false + pending = [] + } + + const output = (data: string | Uint8Array) => { + if (blocked && !boundary) { + pending.push(data) + return + } + deps.write(data) + } + + const frame = (data: Uint8Array) => { + if (data.length === 0 || data[0] !== 0x00) return false + if (blocked && !boundary) { + boundary = true + // Match OpenCode's transport ordering: once the server says replay is + // complete, xterm-generated replies from parsing those queued chunks + // must precede the command typed while the PTY was starting. Keep user + // input blocked until the parser-drain callback below; TerminalTab puts + // parser-generated replies in its separate priority buffer meanwhile. + draining = true + const current = serial + for (const chunk of pending) deps.write(chunk) + pending = [] + deps.write("", () => { + if (serial !== current) return + draining = false + blocked = false + deps.flush() + }) + } + return true + } + + return { attach, blocked: () => blocked, draining: () => draining, frame, output } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts index c14f02c1153..2702421223c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -64,12 +64,13 @@ export interface TerminalFocusRequest { * Multiple creates can be in flight for the same context at once. */ interface SideRequest { contextKey: string - focus: boolean } export interface TerminalStateControls { - /** Record received from `terminal.created`. */ + /** Add a terminal record to one context. */ add(worktreeId: string | null, term: TerminalTabState): void + /** Fill an optimistic terminal record without replacing its xterm-owning object. */ + attach(terminalId: string, input: Pick): boolean /** Drop a terminal from its context (location resolved automatically). * Returns the removed record so callers can react to placement. */ remove(terminalId: string): TerminalTabStateWithContext | undefined @@ -145,7 +146,7 @@ export interface TerminalStateControls { /** Request ids of the in-flight side-terminal creates for a context. */ pendingSide(contextKey: string): boolean /** Mark a side-terminal create as in flight for a context. */ - beginSide(contextKey: string, createId: string, focus?: boolean): void + beginSide(contextKey: string, createId: string): void /** Settle a create request; returns it so the caller can validate. */ completeSide(createId: string): SideRequest | undefined } @@ -299,6 +300,19 @@ export function createTerminalState(selection: Accessor): Termina }) } + const attach = (terminalId: string, input: Pick) => { + const key = contextFor(terminalId) + const term = terminalsByContext()[key ?? ""]?.find((item) => item.id === terminalId) + if (!term) return false + // Keep the record reference stable so Solid's never remounts the + // xterm that already owns focus and buffered input. + term.title = input.title + term.wsUrl = input.wsUrl + term.font = input.font + setTitle(terminalId, input.title) + return true + } + const remove = (terminalId: string): TerminalTabStateWithContext | undefined => { const key = contextFor(terminalId) if (!key) return undefined @@ -528,8 +542,8 @@ export function createTerminalState(selection: Accessor): Termina const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0 - const beginSide = (key: string, createId: string, focus = false) => { - requests.set(createId, { contextKey: key, focus }) + const beginSide = (key: string, createId: string) => { + requests.set(createId, { contextKey: key }) setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] })) } @@ -550,6 +564,7 @@ export function createTerminalState(selection: Accessor): Termina return { add, + attach, remove, contextFor, isScript, @@ -604,12 +619,12 @@ export interface TerminalHandlerDeps { /** Sentinel value for the LOCAL sidebar selection. */ LOCAL: string REVIEW_TAB_ID: string + getFont: () => TerminalFont } /** Correlation ids for terminal create requests. */ function newId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID() - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + return `${TERMINAL_PREFIX}${crypto.randomUUID()}` } /** @@ -644,7 +659,16 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { // project-namespaced state key and must not leak into the message. const sel = deps.getSelection() const id = newId() - deps.state.beginSide(key, id, focus) + deps.state.beginSide(key, id) + deps.state.add(key === deps.LOCAL ? null : key, { + id, + title: "Terminal", + wsUrl: "", + font: deps.getFont(), + placement: "side", + }) + deps.state.setSideActive(key, id) + if (focus) deps.state.requestFocus(id) deps.postMessage({ type: "agentManager.terminal.create", createId: id, @@ -744,6 +768,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true } + deps.state.completeSide(terminalId) deps.state.remove(terminalId) deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true @@ -827,8 +852,6 @@ export interface TerminalMessageHandlerDeps { * than wherever `tabIds()`'s base composition happens to put it. */ onCreated?: (contextKey: string, terminalId: string) => void - /** Side terminal for a context finished creating. */ - onSideCreated?: (contextKey: string, terminalId: string, focus: boolean) => void /** Side terminal create failed for a context. */ onSideError?: (contextKey: string) => void /** Side terminal was closed (locally or by the extension). */ @@ -860,14 +883,16 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { // reloaded (or the context is gone) — close the PTY again instead // of leaking it. const request = deps.state.completeSide(msg.createId) - if (!request || request.contextKey !== key) { + if (!request || request.contextKey !== key || msg.terminalId !== msg.createId) { + deps.state.remove(msg.createId) deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) return } - deps.state.add(key === LOCAL ? null : key, term) - // The newest terminal becomes the visible one in its panel. - deps.state.setSideActive(key, msg.terminalId) - deps.onSideCreated?.(key, msg.terminalId, request.focus) + // The user may have closed the optimistic terminal while the PTY was + // starting. The request was completed above, but its record is gone. + if (!deps.state.attach(msg.terminalId, term)) { + deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) + } return } deps.state.add(key === LOCAL ? null : key, term) @@ -907,7 +932,9 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { return true } if (msg.type === "agentManager.terminal.error") { + const context = msg.createId ? deps.state.contextFor(msg.createId) : undefined const request = msg.createId ? deps.state.completeSide(msg.createId) : undefined + if (msg.createId && context) deps.state.remove(msg.createId) if (request) deps.onSideError?.(request.contextKey) deps.showError(msg.message) return true diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx index 857e4fa634e..0e487aa850a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx @@ -128,6 +128,12 @@ export function PromptRail(props: PromptRailProps) { return items()[entry.type === "overflow" ? entry.index : 0] } + // Dragging the pane splitter, or selecting transcript text, sweeps the pointer + // across the rail with a button held. The splitter tracks the drag on the + // document, so those moves still reach the ticks; treating them as hover would + // pop the navigator open in the middle of a resize. + const dragging = (event: MouseEvent) => event.buttons !== 0 + const openCard = (index: number) => { cancelClose() const entry = entries()[index] @@ -299,7 +305,10 @@ export function PromptRail(props: PromptRailProps) { data-queued={(entry.type === "prompt" && entry.item.queued) || undefined} aria-label={entryLabel(entry)} tabIndex={index() === (focused() ?? 0) ? 0 : -1} - onMouseEnter={() => openCard(index())} + onMouseEnter={(event) => { + if (dragging(event)) return + openCard(index()) + }} onFocus={() => openCard(index())} onClick={() => { if (entry.type === "prompt") props.onSelect(entry.item) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx index 4ca0f754cca..aa4f3e05b5b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx @@ -39,6 +39,8 @@ export interface ModeSwitcherBaseProps { portal?: boolean /** Delay outside dismissal while the popover opens inside a dialog. */ deferDismiss?: boolean + /** Only respond to picker events from this prompt scope. */ + trigger?: string } export const ModeSwitcherBase: Component = (props) => { @@ -51,7 +53,9 @@ export const ModeSwitcherBase: Component = (props) => { let slash = false // Listen for slash command trigger - const onTrigger = () => { + const onTrigger = (event: Event) => { + const source = (event as CustomEvent<{ source?: string }>).detail?.source + if (source !== props.trigger) return slash = true openSelected() } diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 2b3b6cac507..f142dca2719 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -134,6 +134,8 @@ export interface ModelSelectorBaseProps { label?: string /** Additional accessible context for this model setting. */ description?: string + /** Only respond to picker events from this prompt scope. */ + trigger?: string } export const ModelSelectorBase: Component = (props) => { @@ -512,7 +514,11 @@ export const ModelSelectorBase: Component = (props) => { // Register before the popover mounts so programmatic slash-command opens // always restore the prompt before the popover's own Escape handler runs. - const onTrigger = () => setOpen(true) + const onTrigger = (event: Event) => { + const source = (event as CustomEvent<{ source?: string }>).detail?.source + if (source !== props.trigger) return + setOpen(true) + } const onEscape = (e: KeyboardEvent) => { if (!open() || e.key !== "Escape") return e.preventDefault() diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index ff6cd6f3d46..7f58fc6402c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -41,6 +41,8 @@ export interface ThinkingSelectorBaseProps { deferDismiss?: boolean /** Listen for the global prompt trigger event. Defaults to true. */ globalTrigger?: boolean + /** Only respond to picker events from this prompt scope. */ + trigger?: string /** Show the Shift+Tab cycle hint in the trigger tooltip. */ cycleHint?: boolean /** Accessible name for the selector trigger. */ @@ -85,7 +87,9 @@ export const ThinkingSelectorBase: Component = (props refocus() } - const onTrigger = () => { + const onTrigger = (event: Event) => { + const source = (event as CustomEvent<{ source?: string }>).detail?.source + if (source !== props.trigger) return if (rows().length === 0) return onOpen(true) } diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index 6b9cd42bcef..6dbfc9b979c 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -56,11 +56,16 @@ export function useSlashCommand( vscode: VSCodeContext, sandbox: { action: () => void; enabled: Accessor }, exclude?: Set | Accessor>, + include?: Set | Accessor>, + scope?: string, ): SlashCommand { const [server, setServer] = createSignal([]) const [query, setQuery] = createSignal(null) const [index, setIndex] = createSignal(0) const [requested, setRequested] = createSignal(false) + const open = (name: string) => { + window.dispatchEvent(new CustomEvent(name, { detail: { source: scope } })) + } const all: SlashCommandEntry[] = [ { @@ -83,9 +88,9 @@ export function useSlashCommand( { name: "models", description: "Switch the AI model", - hints: [], + hints: ["model"], action: () => { - window.dispatchEvent(new CustomEvent("openModelPicker")) + open("openModelPicker") }, }, { @@ -93,7 +98,7 @@ export function useSlashCommand( description: "Switch the agent mode", hints: ["modes"], action: () => { - window.dispatchEvent(new CustomEvent("openModePicker")) + open("openModePicker") }, }, { @@ -101,7 +106,7 @@ export function useSlashCommand( description: "Switch the reasoning effort", hints: ["variants", "reasoning", "thinking"], action: () => { - window.dispatchEvent(new CustomEvent("openVariantPicker")) + open("openVariantPicker") }, }, { @@ -192,17 +197,23 @@ export function useSlashCommand( return exclude } + const included = () => { + if (typeof include === "function") return include() + return include + } + const client = () => { const set = excluded() - if (!set) return all - return all.filter((c) => !set.has(c.name)) + const only = included() + return all.filter((c) => !set?.has(c.name) && (!only || only.has(c.name))) } const commands = (): SlashCommandEntry[] => { const list = client() const names = new Set(list.map((c) => c.name)) const set = excluded() - const filtered = server().filter((c) => !names.has(c.name) && !set?.has(c.name)) + const only = included() + const filtered = server().filter((c) => !names.has(c.name) && !set?.has(c.name) && (!only || only.has(c.name))) return [...list, ...filtered] } diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css index 4bbe9a022d5..ee628afc260 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css @@ -4,15 +4,21 @@ .prompt-rail { --prompt-rail-width: 16px; - --prompt-rail-gap: 8px; --prompt-rail-step: 14px; --prompt-rail-ease: cubic-bezier(0.22, 1, 0.36, 1); + /* Clears the pane splitter. Agent Manager's sidebar resize handle is 8px wide + and straddles the boundary, so it reaches 4px into the transcript; without + this gutter, aiming slightly wide of the splitter lands on a tick instead of + starting a drag. */ + --prompt-rail-edge: 8px; + position: absolute; - inset-inline-start: max( - 0px, - calc(50% - var(--chat-readable-width) / 2 - var(--prompt-rail-width) - var(--prompt-rail-gap)) - ); + /* Against the panel edge rather than tracking the readable lane: on a wide + editor tab the lane is centered, so a lane-hugging rail would sit right + next to the prose the pointer travels over and open on the way past. The + far edge is somewhere the pointer only goes deliberately. */ + inset-inline-start: var(--prompt-rail-edge); top: 0; bottom: 0; width: var(--prompt-rail-width); @@ -51,15 +57,21 @@ outline: none; } +/* Ticks grow by transform, not by width and height: a rail can hold a hundred + of them, and a compositor-only transition keeps the hover response smooth + instead of laying out the line on every frame. The box is the full 16px x 2px + target and the resting scale draws it as the 9px x 1.5px hairline. */ .prompt-rail-tick-line { display: block; - height: 1.5px; - width: 9px; + height: 2px; + width: var(--prompt-rail-width); border-radius: 1px; background: var(--icon-base); opacity: 0.7; + transform: scale(0.5625, 0.75); + transform-origin: left center; transition: - width 0.24s var(--prompt-rail-ease), + transform 0.24s var(--prompt-rail-ease), opacity 0.2s var(--prompt-rail-ease), background-color 0.2s var(--prompt-rail-ease), box-shadow 0.2s var(--prompt-rail-ease); @@ -70,8 +82,18 @@ opacity: 0.5; } +/* The summary tick keeps a real width: scaling would squash its dash pattern + along with it. At most a couple of these exist, so the width transition is + free here. */ .prompt-rail-tick--overflow .prompt-rail-tick-line { width: 6px; + transform: scale(1, 0.75); + transition: + width 0.24s var(--prompt-rail-ease), + transform 0.24s var(--prompt-rail-ease), + opacity 0.2s var(--prompt-rail-ease), + background-color 0.2s var(--prompt-rail-ease), + box-shadow 0.2s var(--prompt-rail-ease); background: repeating-linear-gradient( to right, var(--icon-weaker) 0, @@ -83,23 +105,32 @@ /* Scroll position: subtle, always-on cue. */ .prompt-rail-tick--active .prompt-rail-tick-line { - width: 13px; + transform: scale(0.8125, 0.75); opacity: 1; background: var(--icon-strong-base); } +.prompt-rail-tick--overflow.prompt-rail-tick--active .prompt-rail-tick-line { + width: 13px; + transform: scale(1, 0.75); +} + /* Pointer or keyboard target: the loud state, full width. */ .prompt-rail-tick:hover .prompt-rail-tick-line, .prompt-rail-tick--open .prompt-rail-tick-line { - width: 100%; - height: 2px; + transform: none; opacity: 1; background: var(--icon-strong-base); } +.prompt-rail-tick--overflow:hover .prompt-rail-tick-line, +.prompt-rail-tick--overflow.prompt-rail-tick--open .prompt-rail-tick-line, +.prompt-rail-tick--overflow:focus-visible .prompt-rail-tick-line { + width: var(--prompt-rail-width); +} + .prompt-rail-tick:focus-visible .prompt-rail-tick-line { - width: 100%; - height: 2px; + transform: none; opacity: 1; background: var(--icon-strong-base); box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder) 50%, transparent); diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index d0fb0339252..859482cae8a 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -745,6 +745,7 @@ export interface AgentManagerStateMessage { /** Last selected sidebar target for seamless project-switch restore. */ activeTarget?: AgentManagerSidebarTarget terminalDestination?: TerminalDestination + terminalFont?: TerminalFont } // A registered Agent Manager project as shown in the sidebar @@ -784,9 +785,9 @@ export interface AgentManagerProjectSessionsMessage { export interface AgentManagerTerminalCreatedMessage { type: "agentManager.terminal.created" - /** Correlates with the create request; lets the webview spot stale - * creates. Deliberately not named `requestId`: that field name is the - * generic webview request/response correlation channel. */ + /** Logical terminal id selected by the webview before PTY startup. + * Deliberately not named `requestId`: that field name is the generic + * webview request/response correlation channel. */ createId: string placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index d4e1cf9a715..48e5a5764ac 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -801,7 +801,7 @@ export interface ShowExistingLocalTerminalRequest { // Create a new xterm terminal in the given worktree context (null = workspace root) export interface AgentManagerTerminalCreateRequest { type: "agentManager.terminal.create" - /** Webview-generated correlation id, echoed back in created/error. */ + /** Webview-generated logical terminal id, echoed back in created/error. */ createId: string placement: TerminalPlacement worktreeId: string | null diff --git a/packages/llm/DESIGN.md b/packages/llm/DESIGN.md new file mode 100644 index 00000000000..22e76969a4c --- /dev/null +++ b/packages/llm/DESIGN.md @@ -0,0 +1,1114 @@ +# AI Library Design + +> Discussion draft. This document describes the intended replacement for the +> current private `@opencode-ai/llm` API. Names and exact TypeScript signatures +> are illustrative until implementation, but the domain boundaries and defaults +> are deliberate. + +## Status + +- Proposed package: `@opencode-ai/ai` +- Initial stable domain: `LLM` +- Release posture: pre-1.0, with a stable-core intent +- Migration posture: clean break; do not preserve compatibility aliases +- Primary audience: general-purpose TypeScript developers using Effect +- Secondary audience: OpenCode and other durable agent runtimes + +The package name leaves room for future domains such as embeddings, images, and +speech. Those domains are not part of this design and should not be forced into +the LLM run/turn model. + +## Goals + +1. Make a useful model call require very little code. +2. Make the default behavior good enough that most callers do not configure it. +3. Let advanced callers inspect, transform, or replace every important stage. +4. Keep provider quirks behind provider and protocol boundaries. +5. Preserve one provider turn as an explicit primitive for durable runtimes. +6. Keep serializable request data separate from process-local execution behavior. +7. Make unsupported combinations fail locally with useful typed errors. +8. Stay Effect-native without making package-specific service provisioning part + of every call site. + +## Non-goals + +- A global provider or model registry +- Durable agent orchestration or persistence +- Session history ownership +- Permission handling +- Cost billing or accounting guarantees +- Runtime model-catalog network requests +- Compatibility with the current private API +- Designing embeddings, image generation, speech, or transcription now + +## Design Principles + +### Progressive disclosure + +The API has four layers: + +1. **Run a model** with `LLM.generate` or `LLM.stream`. +2. **Control one provider turn** with `LLM.generateTurn` or `LLM.streamTurn`. +3. **Customize execution** with model defaults, call options, hooks, and provider + configuration. +4. **Author providers** with experimental provider definitions and protocols. + +Normal documentation should teach only the first layer initially. + +### Values over registries + +Provider definitions, configured providers, models, protocols, tools, and hooks +are immutable values. Importing a provider does not register anything globally. + +### Portable data, local behavior + +Requests, messages, tool definitions, events, usage, and result projections are +plain immutable data with schemas. Configured models, executable tools, hooks, +and provider definitions may contain functions and Effect requirements and are +not serializable. + +### Strong defaults, explicit overrides + +Defaults should make common calls correct without hiding where behavior comes +from. Overrides compose in a documented order and never require patching +installed dependencies. + +## Domain Model + +### Provider Definition + +An immutable, declarative description of a provider integration. It owns model +selection, option schemas, catalog corrections, protocols, and provider-wide +hooks. It is an experimental provider-authoring API. + +### Configured Provider + +A provider definition bound to deployment concerns such as credentials, +endpoint, transport, and provider headers. + +`configure(...)` is intentionally deployment-only. It does not establish hidden +generation defaults. + +### Model + +A process-local executable model value selected from a configured provider. It +contains identity, capabilities, pricing metadata, provider-specific option +types, reusable request-behavior defaults, and hidden execution behavior. + +Normal users do not need to learn the current `Route` composite. Protocol, +endpoint, auth, transport, and hooks are bound behind `Model`. + +### Request + +Portable, model-independent input for a model call. It may contain system +instructions, messages, tool definitions, generation controls, output intent, +cache policy, and metadata. It does not contain a configured model, executable +tool handlers, or hooks. + +### Provider Turn + +Exactly one request to a model provider and its normalized response. It does not +execute local tools or continue the conversation. + +### Model Run + +A complete interaction consisting of one or more provider turns. A run executes +local tools, appends their results, and continues until the model completes or a +stopping condition matches. + +### TurnResult + +The result of exactly one provider turn. + +### GenerateResult + +The result of a complete model run. It preserves every turn, tool activity, +aggregate usage, and estimated cost while exposing shortcuts to the final output. + +### Protocol + +The provider-wire contract that lowers portable requests into provider-native +bodies and raises provider-native stream events into normalized turn events. +Protocols are public, reusable, fully inspectable, and immutably patchable, but +the entire protocol-authoring API is experimental. + +## Happy Path + +### Effect + +```ts +import { Effect } from "effect" +import { LLM } from "@opencode-ai/ai" +import { OpenAI } from "@opencode-ai/ai/providers/openai" + +// Environment-based credentials are a provider default. No LLMClient layer is +// required: the Effect exposes standard runtime dependencies directly. +const model = OpenAI.model("gpt-4.1-mini") + +const program = Effect.gen(function* () { + const result = yield* LLM.generate({ + model, + system: "You are concise.", + prompt: "Explain Effect in one sentence.", + }) + + // `generate` always returns GenerateResult, even when the run has one turn. + console.log(result.text) + console.log(result.turns.length) // 1 + console.log(result.usage) + console.log(result.cost) // Estimated cost, or undefined if any turn is unpriced. +}) +``` + +The required Effect environment should contain standard services plus services +required by tools and hooks. It should not contain an `LLMClient` wrapper service. + +### Current API + +The current README appears similarly small but omits the package-specific service +and layer required at runtime: + +```ts +// Current API: this request contains an executable model/route value. +const request = LLM.request({ + model: OpenAI.configure({ apiKey }).responses("gpt-4o-mini"), + prompt: "Say hello.", +}) + +// Current API: this performs one provider turn, despite the broad name. +const response = yield * LLM.generate(request) + +// Current API: execution also needs LLMClient.layer and RequestExecutor services. +``` + +The proposal removes mandatory request construction, removes package-specific +runtime provisioning, and makes `generate` mean a complete run. + +## Provider And Model Selection + +### Environment defaults + +```ts +import { OpenAI } from "@opencode-ai/ai/providers/openai" + +// Open strings receive autocomplete for IDs from the generated models.dev +// snapshot but continue to accept newly released and fine-tuned model IDs. +const model = OpenAI.model("gpt-4.1-mini") +``` + +### Deployment configuration + +```ts +const openai = OpenAI.configure({ + apiKey, + baseURL: "https://gateway.example.com/openai/v1", + headers: { + "x-tenant": "acme", + }, +}) + +const model = openai.model("gpt-4.1-mini") +``` + +`configure(...)` owns deployment concerns only: + +- Credentials and authentication +- Base URL and deployment location +- Transport selection +- Provider/deployment headers +- Other provider-specific connection setup + +It does not own temperature, maximum output tokens, cache policy, retry policy, +tools, output schema, or system instructions. + +### Reusable model defaults + +```ts +const model = OpenAI.model("gpt-4.1-mini", { + generation: { + temperature: 0.2, + maxTokens: 2_000, + }, + cache: "auto", + provider: { + store: false, + }, +}) +``` + +The second argument may default request behavior but not prompt/history or +executable tools. Call-level values override model defaults. + +Provider-specific options are inferred from the concrete model: + +```ts +yield * + LLM.generate({ + model: OpenAI.model("gpt-4.1-mini"), + prompt: "Hello", + provider: { + store: false, + // OpenAI-specific autocomplete here; no `{ openai: ... }` nesting. + }, + }) +``` + +Code choosing between providers dynamically must narrow the model before using +provider-specific options. Portable generation controls remain available without +narrowing. + +### Current API + +```ts +// Current API mixes deployment configuration and reusable request behavior. +const model = OpenAI.configure({ + apiKey, + generation: { maxTokens: 160 }, + providerOptions: { + openai: { store: false }, + }, +}).model("gpt-4o-mini") +``` + +The proposal separates deployment configuration from selected-model behavior and +removes provider-keyed option bags when a concrete model already identifies the +provider. + +## Requests + +### Inline input + +```ts +const result = + yield * + LLM.generate({ + model, + system: "You are concise.", + prompt: "Summarize this pull request.", + generation: { maxTokens: 500 }, + }) +``` + +### Reusable portable request + +```ts +const request = LLM.request({ + system: "You are concise.", + prompt: "Summarize this pull request.", + generation: { maxTokens: 500 }, +}) + +// Bind process-local execution behavior only when running. +const result = yield * LLM.generate({ model, request }) +``` + +`LLM.request(...)` returns a plain immutable object. Use ordinary object spread +to derive another request: + +```ts +const longer = { + ...request, + generation: { + ...request.generation, + maxTokens: 1_000, + }, +} +``` + +There is no `LLM.updateRequest(...)` helper and no request Schema class. + +### Conversation history + +```ts +import { Message } from "@opencode-ai/ai" + +const request = LLM.request({ + system: "You are concise.", + messages: [ + Message.user("What is Effect?"), + Message.assistant("A TypeScript library for typed functional effects."), + Message.user("Why would I use it?"), + ], +}) +``` + +Message helpers return plain immutable data. Object literals remain valid when +they satisfy the same input type. + +`system` stays separate from chronological messages because it is the initial +privileged instruction. A chronological system message represents an instruction +change at a specific point in history. + +## Complete Runs + +### Automatic local tool loop + +```ts +import { Effect, Schema } from "effect" +import { LLM, Tool } from "@opencode-ai/ai" + +const tools = { + getWeather: Tool.make({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), + + // Tool service requirements and typed errors flow into LLM.generate's + // Effect environment/error model instead of being erased. + execute: ({ city }) => Weather.get(city), + + // Expected domain failures need an explicit model-visible representation. + formatError: (error) => ({ + type: "text", + text: `Weather lookup failed: ${error.message}`, + }), + }), +} + +const result = + yield * + LLM.generate({ + model, + prompt: "What is the weather in London?", + tools, + }) + +// The runtime advertises definitions, dispatches calls, records results, and +// continues provider turns automatically. +console.log(result.text) +console.log(result.turns) +console.log(result.toolExecutions) +``` + +The default stopping condition is equivalent to: + +```ts +stopWhen: StopWhen.turnCount(20) +``` + +This matches the Vercel AI SDK `ToolLoopAgent` default. Reaching the limit is a +successful result with `stopReason: "max-turns"`, not an Effect failure. + +### Custom stopping + +```ts +const result = + yield * + LLM.generate({ + model, + prompt, + tools, + stopWhen: StopWhen.any(StopWhen.turnCount(8), StopWhen.hasToolCall("finalize")), + }) +``` + +`stopWhen` accepts one predicate. Composition is explicit through combinators +such as `StopWhen.any`, `StopWhen.all`, and `StopWhen.not`. + +Successful run stop reasons are closed: + +```ts +type RunStopReason = "completed" | "max-turns" | "stop-condition" +``` + +### Tool concurrency + +Independent tool calls emitted in one turn run concurrently with a bounded, +configurable concurrency limit. Results are appended in deterministic emitted +order. The runtime does not infer dependencies between tool calls; the model must +request dependent calls in separate turns. + +Tools may declare an optional timeout. The overall run timeout still applies. + +### Current API + +Today callers must manually bridge every layer: + +```ts +const request = LLM.request({ + model, + prompt, + tools: Tool.toDefinitions(tools), +}) + +const events = yield * LLM.stream(request).pipe(Stream.runCollect) +const call = Array.from(events).find(LLMEvent.is.toolCall) + +if (call && !call.providerExecuted) { + const dispatched = yield * ToolRuntime.dispatch(tools, call) + const followUp = LLM.updateRequest(request, { + messages: [...request.messages, Message.assistant([call]), Message.tool({ ...call, result: dispatched.result })], + }) + // Caller must invoke the provider again and repeat the loop. +} +``` + +That explicit flow remains possible through turn APIs, but it is no longer the +only tool experience. + +## One Provider Turn + +OpenCode and other durable runtimes need to own persistence, tool settlement, +and continuation. They use the explicit turn API: + +```ts +const result = + yield * + LLM.generateTurn({ + model, + request, + // Definitions only. generateTurn never dispatches local handlers. + tools: { + getWeather: Tool.definition({ + description: "Get current weather for a city.", + parameters: WeatherInput, + }), + }, + }) + +// Persist the TurnResult and settle calls durably before the next turn. +for (const call of result.toolCalls) { + // Application-owned dispatch and persistence. +} +``` + +`generateTurn` and `streamTurn` make exactly one provider request. They never +execute a local tool and never continue automatically. + +This separation is load-bearing: + +- `generate` / `stream`: complete Model Run +- `generateTurn` / `streamTurn`: one Provider Turn + +## Portable Tool Definitions + +A portable request may declare serializable definitions, but executable handlers +are bound at run time: + +```ts +const request = LLM.request({ + prompt: "What is the weather in London?", + tools: { + getWeather: Tool.definition({ + description: "Get current weather for a city.", + parameters: WeatherInput, + }), + }, +}) + +const result = + yield * + LLM.generate({ + model, + request, + tools: { + getWeather: Tool.make({ + description: "Get current weather for a city.", + parameters: WeatherInput, + success: WeatherOutput, + execute: getWeather, + formatError, + }), + }, + }) +``` + +Definitions and handlers match by record key. Before the first provider call, +the runtime validates that every local definition has a compatible executable +binding. Missing or incompatible bindings fail with a typed tool-binding error. + +Provider-hosted tools are distinct typed values: + +```ts +const result = + yield * + LLM.generate({ + model: OpenAI.model("gpt-4.1"), + prompt: "Find today's relevant announcements.", + tools: { + search: OpenAI.tool.webSearch({ searchContextSize: "medium" }), + }, + }) +``` + +Hosted tools do not pretend to have local handlers, and callers do not inspect a +`providerExecuted` boolean to decide whether dispatch is safe. + +## Streaming + +### Run stream + +`LLM.stream` returns an Effect `Stream`. +Run events explicitly expose orchestration boundaries: + +```ts +const program = LLM.stream({ model, prompt, tools }).pipe( + Stream.tap((event) => + Effect.sync(() => { + switch (event.type) { + case "run-start": + break + case "turn-start": + break + case "turn-event": + // Normalized text, reasoning, tool-call, usage, and finish events. + if (event.event.type === "text-delta") { + process.stdout.write(event.event.text) + } + break + case "tool-start": + break + case "tool-finish": + break + case "turn-finish": + break + case "run-finish": + // Contains the same full GenerateResult returned by LLM.generate. + console.log(event.result.usage) + break + } + }), + ), + Stream.runDrain, +) +``` + +Exact event tag spelling remains an implementation detail to finalize, but the +algebra is settled: + +- A separate `RunEvent` union for run, turn, and tool lifecycle +- A focused `TurnEvent` union for normalized provider output +- `streamTurn` emits only `TurnEvent` +- The terminal run event contains the full `GenerateResult` + +External cancellation remains Effect interruption. It does not fabricate a +successful result with an `interrupted` stop reason. + +## Structured Output + +Structured output is an option on `generate`, not a separate operation: + +```ts +const Weather = Schema.Struct({ + city: Schema.String, + forecast: Schema.String, + highCelsius: Schema.Number, +}) + +const result = + yield * + LLM.generate({ + model, + prompt: "Give me today's weather for London.", + output: Weather, + }) + +// Inferred from Weather. +result.output.city +``` + +The model declaration and protocol select the best reliable strategy: + +1. Provider-native structured output when supported and reliable +2. Forced tool output when required as a compatibility fallback +3. Typed unsupported-capability failure before network execution when neither is + available + +Advanced callers may override the strategy when exact provider semantics matter. + +### Current API + +```ts +// Current API is a separate operation and always forces a synthetic tool. +const result = + yield * + LLM.generateObject({ + model, + prompt, + schema: Weather, + }) +``` + +The proposal unifies generation and lets capabilities choose the strategy rather +than permanently encoding one cross-provider workaround. + +## Model Catalog + +`models.dev` is the release-time source for: + +- Model ID suggestions +- Capabilities and modalities +- Context and output limits +- Pricing +- Other available model metadata + +The package ships a generated, versioned snapshot. Normal execution performs no +catalog network requests. + +Provider definitions may correct generated metadata where protocol-specific +knowledge is more accurate. Precedence is: + +```text +models.dev snapshot + < provider-definition correction + < provider configuration override + < model-selection override + < call override +``` + +Unknown model IDs inherit only capabilities guaranteed by the selected protocol. +Unsupported request capabilities fail before network execution unless the caller +explicitly overrides the model declaration. + +## Usage And Cost + +`GenerateResult` aggregates normalized usage across every turn, including cache +read/write usage where providers report it. + +It also exposes estimated cost using the generated models.dev pricing snapshot: + +```ts +result.usage.inputTokens +result.usage.outputTokens +result.usage.cacheReadInputTokens +result.usage.cacheWriteInputTokens + +result.cost?.total +result.cost?.currency // e.g. "USD" +``` + +Cost is an estimate, not a billing guarantee. If reliable pricing is unavailable +for any turn, aggregate run cost is unavailable rather than partial or silently +zero. Per-turn metadata should retain the catalog/pricing identity used so an +estimate can be explained. + +## Caching + +Prompt caching remains `"auto"` by default. The library places protocol-aware +cache boundaries where explicit caching is supported and does nothing on the wire +where providers cache implicitly. + +```ts +yield * + LLM.generate({ + model, + prompt, + cache: "none", // Explicit opt-out. + }) +``` + +Granular cache policy remains available as an advanced request option. + +## Retries, Timeouts, And Cancellation + +### Retries + +The default retry policy is deliberately conservative: + +- Retry bounded transient transport and rate-limit failures +- Retry only before observable output +- Never silently retry after ambiguous tool execution or other side effects +- Allow each call to override or disable retry behavior + +Retry configuration is call-scoped only. Provider and model configuration do not +silently inherit custom retry policies. + +### Timeouts + +```ts +yield * + LLM.generate({ + model, + prompt, + timeout: "2 minutes", // Entire run, including tools. + turnTimeout: "30 seconds", // Each provider turn. + tools, + }) +``` + +Exact Duration input spelling follows Effect conventions. Individual tools may +also declare optional timeouts. + +### Cancellation + +- Effect API: fiber interruption +- Promise API: `AbortSignal`, rejecting with a recognizable abort error +- Cancellation is not a successful run stop reason + +## Hooks + +Stable high-level hooks exist at five named stages: + +1. Canonical request +2. Provider-native body +3. Prepared transport request +4. Normalized event +5. Error + +Hooks are Effectful. They may transform the stage value or fail with a typed +error. They may not secretly short-circuit execution, synthesize a response, +retry, or redirect control flow. + +```ts +const model = OpenAI.model("gpt-4.1", { + hooks: { + request: (request) => + Effect.succeed({ + ...request, + metadata: { ...request.metadata, tenant: "acme" }, + }), + body: (body, context) => auditBody(body, context), + transport: (request) => signInternalGatewayRequest(request), + event: (event) => redactProviderMetadata(event), + error: (error) => classifyInternalError(error), + }, +}) +``` + +Hook scopes compose in this order: + +```text +provider-definition hooks -> model hooks -> call hooks +``` + +Each hook sees the prior hook's output. Replacement requires an explicit +definition-level patch, not accidental last-writer-wins semantics. + +Provider-definition hooks are authored by provider integrations. They are not +passed through `Provider.configure(...)`, which remains deployment-only. + +## HTTP And Provider Escape Hatches + +The request customization ladder is: + +1. Portable generation controls +2. Model-typed `provider` options +3. Stable staged hooks +4. Serializable HTTP/body overlays +5. Experimental provider-definition or protocol patching + +```ts +yield * + LLM.generate({ + model, + prompt, + http: { + headers: { "x-experimental": "1" }, + query: { debug: "true" }, + body: { newlyReleasedProviderField: true }, + }, + }) +``` + +Raw overlays are intentional last-resort support for provider features that ship +before the library has a typed option. + +## Provider-Native Metadata + +Normalized message/content/event unions remain closed and exhaustive. Unknown or +provider-required round-trip data lives in caller-writable `providerMetadata`. + +```ts +const assistant = Message.assistant([ + { + type: "reasoning", + text: "...", + providerMetadata: { + openai: { + // Opaque provider data needed for replay or continuation. + }, + }, + }, +]) +``` + +Protocols validate metadata they consume. The field is an escape hatch, not a +portable semantic guarantee. + +## Error Model + +The Effect error channel is a tagged domain union rather than one `LLMError` +wrapper with nested reasons. Illustrative categories: + +```ts +type LLMError = + | AuthenticationError + | InvalidRequestError + | UnsupportedCapabilityError + | ToolBindingError + | TransportError + | ProviderResponseError + | InvalidProviderOutputError + | HookError +``` + +Each error retains relevant provider/model/turn/stage context and its underlying +cause where available. + +Expected tool errors keep their own typed error channel. `Tool.make` requires an +explicit mapping before such errors become model-visible tool results. Expected +mapped failures let the model recover; defects and interruption fail the run. + +## Observability + +The core library emits Effect-native spans and metrics for: + +- Model runs +- Provider turns +- Provider requests +- Retries +- Tool executions + +Default telemetry records metadata only: + +- Provider and model identity +- Timing +- Token/cache usage +- Estimated cost availability +- Finish and stop reasons +- Retry counts +- Tool names + +Prompts, model output, tool arguments, and tool results are never recorded by +default. Explicit hooks or telemetry configuration may opt into content capture. + +## Promise API + +Promise wrappers live at a separate subpath so the root remains unambiguously +Effect-first: + +```ts +import { LLM } from "@opencode-ai/ai/promise" +import { OpenAI } from "@opencode-ai/ai/providers/openai" + +const result = await LLM.generate({ + model: OpenAI.model("gpt-4.1-mini"), + prompt: "Explain Effect in one sentence.", + signal: abortController.signal, +}) +``` + +Streaming returns an `AsyncIterable`: + +```ts +for await (const event of LLM.stream({ model, prompt, signal })) { + if (event.type === "turn-event" && event.event.type === "text-delta") { + process.stdout.write(event.event.text) + } +} +``` + +Top-level Promise functions use a default runtime for built-in services. Custom +Effect service requirements use a configured client: + +```ts +const client = LLM.makeClient({ + layer: Layer.mergeAll(WeatherLive, AuditLive), +}) + +const result = await client.generate({ model, prompt, tools }) +``` + +The Promise API mirrors Effect semantics. It does not invent different run, +error, stopping, or cancellation behavior. + +## Schemas + +Schemas live in a dedicated namespace/subpath instead of flooding root exports: + +```ts +import { LLMSchema } from "@opencode-ai/ai/schema" + +const request = yield * Schema.decodeUnknown(LLMSchema.Request)(input) +``` + +Schemas cover only serializable domain values: + +- Requests and messages +- Portable tool definitions +- Turn and run events +- Serializable result projections +- Usage and cost estimates +- Tagged errors where serializable +- Provider metadata containers + +Configured models, executable tools, hooks, provider definitions, and protocols +are process-local behavior and do not receive fake serialization schemas. + +## Provider Authoring + +Provider authoring is public but experimental. + +### Declarative provider definition + +```ts +import { Provider, Protocol } from "@opencode-ai/ai/provider" + +export const ExampleAI = Provider.define({ + id: "example", + options: ExampleProviderOptions, + configure: configureExampleDeployment, + protocols: { + responses: ExampleResponses, + }, + models: ({ deployment, catalog }) => ({ + model: (id, defaults) => + Provider.model({ + id, + deployment, + protocol: ExampleResponses, + metadata: catalog.model(id), + defaults, + }), + }), + catalog: generatedExampleCatalog, + corrections: exampleCatalogCorrections, + hooks: exampleProviderHooks, +}) +``` + +The exact builder fields need implementation design, but it must remain one +declarative immutable object, infer provider option types, and support `.with(...)` +patching. It must not register globally. + +Built-ins export their immutable definition for advanced forking: + +```ts +import { OpenAI } from "@opencode-ai/ai/providers/openai" + +const PatchedOpenAI = OpenAI.definition.with({ + protocols: { + responses: OpenAI.protocols.responses.with({ + // Explicit immutable stage patch. + body: { + fromRequest: patchResponsesBody, + }, + }), + }, +}) +``` + +### Protocols + +A protocol exposes all native types and stages: + +- Provider-native request body and schema +- Transport frame type +- Provider-native event and schema +- Parser state +- Request lowering +- Event stepping +- Terminal detection and final flushing + +Every stage is immutably patchable. This is deliberately more open than the AI +SDK integrations that motivated this package. + +```ts +const PatchedResponses = OpenAIResponses.with({ + body: { + fromRequest: (request) => + OpenAIResponses.body.fromRequest(request).pipe(Effect.map((body) => ({ ...body, custom_field: true }))), + }, + stream: { + step: patchResponsesStep, + }, +}) +``` + +Protocol body, frame, native event, and parser-state types are exported. Because +provider wire formats change often, these types and patch APIs are explicitly +experimental and do not receive the high-level API's compatibility promise. + +## Package Surface + +Illustrative export layout: + +```text +@opencode-ai/ai + LLM + Message + Tool + StopWhen + stable domain types + +@opencode-ai/ai/promise + Promise/AsyncIterable LLM facade + +@opencode-ai/ai/schema + serializable domain schemas + +@opencode-ai/ai/provider + experimental Provider and Protocol authoring APIs + +@opencode-ai/ai/providers/openai +@opencode-ai/ai/providers/anthropic +@opencode-ai/ai/providers/google +... +``` + +Providers are imported through individual subpaths. The root does not export all +providers, and there is no preferred all-providers barrel. + +## Defaults + +| Concern | Default | +| ---------------------------- | --------------------------------------------------- | +| `LLM.generate` semantics | Complete Model Run | +| `LLM.generateTurn` semantics | Exactly one Provider Turn | +| Maximum turns | 20 | +| Turn-limit outcome | Successful `max-turns` result | +| Tool execution | Automatic in runs | +| Tool concurrency | Concurrent, bounded, deterministic result order | +| Prompt caching | `auto` | +| Retries | Conservative, pre-output transient failures only | +| Structured output | Capability-selected native or tool strategy | +| Capability mismatch | Typed failure before network execution | +| Unknown model capability | Conservative protocol baseline | +| Telemetry content | Metadata only | +| Cost | Estimated aggregate or unavailable | +| Cancellation | Interruption/rejection, never successful completion | + +## Clean-break Migration + +The redesign intentionally removes or changes these current concepts: + +| Current | Proposed | +| --------------------------------------- | ----------------------------------------------------------- | +| `@opencode-ai/llm` | `@opencode-ai/ai` | +| Mandatory `LLM.request({ model, ... })` | Inline calls or model-free portable requests | +| `LLM.generate` means one turn | `LLM.generate` means complete run | +| `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn | +| `LLMClient.layer` requirement | Standard Effect requirements exposed directly | +| Public `Route` mental model | Hidden behind executable `Model` | +| `Provider.make` structural helper | Experimental declarative `Provider.define` | +| Schema classes as canonical values | Plain immutable values plus schema subpath | +| `LLM.updateRequest` | Object spread | +| `Tool.toDefinitions` in normal calls | Named executable tool records | +| Manual `ToolRuntime.dispatch` loop | Automatic run dispatch; explicit turn API for orchestration | +| `providerOptions: { openai: ... }` | Model-typed `provider: ...` | +| `generateObject` | Typed `output` option on `generate` | +| One event union for provider output | Separate `TurnEvent` and `RunEvent` unions | +| `providerExecuted` dispatch check | Distinct hosted-tool constructors | +| One wrapped `LLMError` | Tagged domain error union | + +OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable +prompt admission, persistence, permission, tool settlement, and continuation +boundaries. It should not use the automatic run API for Session orchestration. + +## Remaining Implementation-level Questions + +These do not reopen the main design: + +1. Exact `RunEvent` and `TurnEvent` tag names and payloads +2. Exact `GenerateResult` shortcut fields for text, reasoning, output, and messages +3. Exact Provider definition TypeScript shape needed for strong inference +4. Exact protocol `.with(...)` patch syntax and replacement semantics +5. Exact Duration input fields and names +6. Exact models.dev generation pipeline and correction-file format +7. Exact cost representation and decimal arithmetic strategy +8. Exact default retry schedule and bounded tool concurrency number +9. Whether request-level serializable HTTP overlays belong in the stable schema +10. Which tagged errors are serializable versus process-local + +These should be resolved with call-site sketches and implementation spikes rather +than by changing the domain boundaries above. diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index 0a227f5bbcd..af7a842c5d6 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -238,7 +238,7 @@ const inspectFakeProvider = Effect.gen(function* () { // Provide the LLM runtime and the HTTP request executor once. Keep one path // enabled at a time so the tutorial can demonstrate generate, prepare, stream, // or tool-loop behavior without spending tokens on every example. -const requestExecutorLayer = RequestExecutor.defaultLayer +const requestExecutorLayer = RequestExecutor.fetchLayer const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps)) diff --git a/packages/llm/package.json b/packages/llm/package.json index 7878a4f97aa..971483d0921 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -47,7 +47,8 @@ "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", - "effect": "catalog:" + "effect": "catalog:", + "@opencode-ai/schema": "workspace:*" }, "peerDependencies": {} } diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index a37cd2c9a75..1c0dcd32a43 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -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, diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 80412ca9ddb..c447a1a39d3 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -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 // ============================================================================= // 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): BedrockTool[] => { +const lowerTools = ( + compatibility: ModelToolSchemaCompatibility | undefined, + breakpoints: BedrockCache.Breakpoints, + tools: ReadonlyArray, +): 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) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index c8fa34b5096..c4bb9476a49 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ProviderMetadata, @@ -19,9 +20,10 @@ 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 IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES) +const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES) export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" // ============================================================================= @@ -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) => @@ -182,7 +184,7 @@ const lowerToolConfig = (toolChoice: NonNullable) => const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) { if (part.type === "text") return { text: part.text } - const media = yield* ProviderShared.validateMedia("Gemini", part, IMAGE_MIMES) + const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES) return { inlineData: { mimeType: media.mime, data: media.base64 } } }) @@ -275,7 +277,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR }) for (const item of content) { if (item.type === "text") continue - const media = yield* ProviderShared.validateToolFile("Gemini", item, IMAGE_MIMES) + const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES) parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } }) } } @@ -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 @@ -407,21 +418,35 @@ const step = (state: ParserState, event: GeminiEvent) => { if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought) reasoningSignature = part.thoughtSignature if ("text" in part && part.text.length > 0) { - lifecycle = part.thought - ? Lifecycle.reasoningDelta( - lifecycle, - events, - "reasoning-0", - part.text, - part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, - ) - : Lifecycle.textDelta(lifecycle, events, "text-0", part.text) + if (part.thought) { + lifecycle = Lifecycle.reasoningDelta( + lifecycle, + events, + "reasoning-0", + part.text, + part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, + ) + continue + } + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, + ) + lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text) continue } if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, + ) lifecycle = Lifecycle.stepStart(lifecycle, events) events.push( LLMEvent.toolCall({ diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 98c7a9b0755..c6ac16c7b3b 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -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 }, @@ -411,7 +419,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) => if (delta?.reasoning_content) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content) - if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) + if (delta?.content) { + lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") + lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) + } + + if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") for (const tool of toolDeltas) { const result = ToolStream.appendOrStart( diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 33aafaf6d28..2e6b67aec72 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -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" @@ -53,7 +55,7 @@ const OpenAIResponsesReasoningSummaryText = Schema.Struct({ const OpenAIResponsesReasoningItem = Schema.Struct({ type: Schema.tag("reasoning"), - id: Schema.String, + id: Schema.optionalKey(Schema.String), summary: Schema.Array(OpenAIResponsesReasoningSummaryText), encrypted_content: optionalNull(Schema.String), }) @@ -101,6 +103,7 @@ type OpenAIResponsesReasoningInput = { summary: Array<{ type: "summary_text"; text: string }> encrypted_content?: string | null } +type OpenAIResponsesReasoningReplay = Omit const OpenAIResponsesTool = Schema.Struct({ type: Schema.tag("function"), @@ -253,11 +256,13 @@ 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, }) const lowerToolChoice = (toolChoice: NonNullable) => @@ -364,7 +369,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ if (message.role === "assistant") { const content: TextPart[] = [] - const reasoningItems: Record = {} + const reasoningItems: Record = {} const reasoningReferences = new Set() const hostedToolReferences = new Set() const flushText = () => { @@ -381,7 +386,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ flushText() const reasoning = lowerReasoning(part) if (!reasoning) continue - if (store !== false && reasoning.id) { + if (store !== false) { if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id }) reasoningReferences.add(reasoning.id) continue @@ -393,8 +398,13 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ existing.encrypted_content = reasoning.encrypted_content continue } - reasoningItems[reasoning.id] = reasoning - input.push(reasoning) + const replay = { + type: reasoning.type, + summary: reasoning.summary, + encrypted_content: reasoning.encrypted_content, + } + reasoningItems[reasoning.id] = replay + input.push(replay) continue } if (part.type === "tool-call") { @@ -468,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, @@ -972,6 +988,7 @@ export const route = Route.make({ endpoint, auth, transport: httpTransport, + defaults: { providerOptions: { openai: { store: false } } }, }) const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage)) @@ -999,6 +1016,7 @@ export const webSocketRoute = Route.make({ endpoint, auth, transport: webSocketTransport, + defaults: { providerOptions: { openai: { store: false } } }, }) export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 4a1fed55398..173dc511bb0 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -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 { @@ -19,43 +19,11 @@ export { isRecord } export const Json = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownSync(Json) export const encodeJson = Schema.encodeSync(Json) +const isJson = Schema.is(Schema.Json) export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) export const optionalNull = (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 @@ -188,8 +156,11 @@ export const parseToolInput = (route: string, name: string, raw: string) => parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`) export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const -export const MAX_MEDIA_ENCODED_BYTES = 8 * 1024 * 1024 -export const MAX_MEDIA_DECODED_BYTES = 6 * 1024 * 1024 +export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const +export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const +export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const +export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024 +export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024 const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ @@ -240,8 +211,14 @@ export const validateToolFile = (route: string, part: ToolFileContent, supported export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") export const toolResultText = (part: ToolResultPart) => { - if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) - if (part.result.type === "content") return encodeJson(part.result.value) + if (part.result.type === "text") return String(part.result.value) + if (part.result.type === "error") { + const value = part.result.value + const prototype = + typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) + const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null + return structured && isJson(value) ? encodeJson(value) : String(value) + } return encodeJson(part.result.value) } diff --git a/packages/llm/src/protocols/utils/gemini-tool-schema.ts b/packages/llm/src/protocols/utils/gemini-tool-schema.ts index 7690b2e6001..efdbe3f6ec6 100644 --- a/packages/llm/src/protocols/utils/gemini-tool-schema.ts +++ b/packages/llm/src/protocols/utils/gemini-tool-schema.ts @@ -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)) diff --git a/packages/llm/src/protocols/utils/tool-schema.ts b/packages/llm/src/protocols/utils/tool-schema.ts new file mode 100644 index 00000000000..3a311eb34ce --- /dev/null +++ b/packages/llm/src/protocols/utils/tool-schema.ts @@ -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) => { + 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 diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts index 7f69583418b..c0406f3da02 100644 --- a/packages/llm/src/provider.ts +++ b/packages/llm/src/provider.ts @@ -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 /** * Advanced structural provider definition helper. Built-in providers should diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 5b5bc5ab2d5..d3b41f5817f 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -164,13 +164,20 @@ export interface GenerateMethod { export class Service extends Context.Service()("@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 { /** Route id used in diagnostics and prepared request metadata. */ @@ -374,17 +381,12 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) = const generateWith = (stream: Interface["stream"]) => Effect.fn("LLM.generate")(function* (request: LLMRequest) { - return new LLMResponse( - yield* stream(request).pipe( - Stream.runFold( - () => ({ events: [] as LLMEvent[], usage: undefined as LLMResponse["usage"] }), - (acc, event) => { - acc.events.push(event) - if ("usage" in event && event.usage !== undefined) acc.usage = event.usage - return acc - }, - ), - ), + const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) + const response = LLMResponse.complete(state) + if (response) return response + return yield* ProviderShared.eventError( + `${request.model.provider}/${request.model.route.id}`, + "Provider stream ended without a terminal finish event", ) }) diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts index 10d8273272a..b2f679c6835 100644 --- a/packages/llm/src/route/executor.ts +++ b/packages/llm/src/route/executor.ts @@ -380,6 +380,6 @@ export const layer: Layer.Layer = Layer.e }), ) -export const defaultLayer = layer.pipe(Layer.provide(FetchHttpClient.layer)) +export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer)) export * as RequestExecutor from "./executor" diff --git a/packages/llm/src/route/transport/http.ts b/packages/llm/src/route/transport/http.ts index 00508957a76..acc52c6ea17 100644 --- a/packages/llm/src/route/transport/http.ts +++ b/packages/llm/src/route/transport/http.ts @@ -28,9 +28,56 @@ const applyQuery = (url: string, query: Record | 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) => + Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key)) + const bodyWithOverlay = (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) } diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index 35546ca30b4..072e4e83892 100644 --- a/packages/llm/src/schema/errors.ts +++ b/packages/llm/src/schema/errors.ts @@ -202,6 +202,6 @@ export class LLMError extends Schema.TaggedErrorClass()("LLM.Error", { */ export class ToolFailure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { message: Schema.String, - error: Schema.optional(Schema.Defect), + error: Schema.optional(Schema.Defect()), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index a685f07d5e1..98fcc9a24d4 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ModelSchema } from "./options" -import { ToolOutput, ToolResultValue } from "./messages" +import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { ProviderFailureClassification } from "./errors" /** @@ -175,7 +175,7 @@ export const ToolError = Schema.Struct({ id: ToolCallID, name: Schema.String, message: Schema.String, - error: Schema.optional(Schema.Defect), + error: Schema.optional(Schema.Defect()), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ToolError" }) export type ToolError = Schema.Schema.Type @@ -335,9 +335,234 @@ const responseUsage = (events: ReadonlyArray) => undefined, ) +interface ContentAssembly { + readonly contentIndex: number + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ToolInputAssembly { + readonly name: string + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ResponseState { + readonly events: ReadonlyArray + readonly message: Message + readonly usage?: Usage + readonly finishReason?: FinishReason + readonly textParts: Readonly> + readonly reasoningParts: Readonly> + readonly toolInputs: Readonly> +} + +const emptyResponseState = (): ResponseState => ({ + events: [], + message: Message.assistant([]), + textParts: {}, + reasoningParts: {}, + toolInputs: {}, +}) + +const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => { + const events = [...state.events, event] + if (LLMEvent.is.finish(event)) { + return { + ...state, + events, + usage: event.usage ?? state.usage, + finishReason: event.reason, + } + } + if (LLMEvent.is.providerError(event)) { + return { + ...state, + events, + finishReason: state.finishReason ?? "error", + } + } + return { + ...state, + events, + usage: "usage" in event && event.usage !== undefined ? event.usage : state.usage, + } +} + +const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata } + +const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata } + +const contentWith = (state: ResponseState, content: ReadonlyArray): ResponseState => ({ + ...state, + message: Message.assistant(content), +}) + +const appendContent = (state: ResponseState, part: ContentPart) => contentWith(state, [...state.message.content, part]) + +const replaceContent = (state: ResponseState, index: number, part: ContentPart) => + contentWith( + state, + state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)), + ) + +const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.textParts[id]) return state + return { + ...appendContent(state, textContent("", providerMetadata)), + textParts: { + ...state.textParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => { + const started = ensureText(state, event.id, event.providerMetadata) + const current = started.textParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)), + textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => { + const current = state.textParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)), + textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.reasoningParts[id]) return state + return { + ...appendContent(state, reasoningContent("", providerMetadata)), + reasoningParts: { + ...state.reasoningParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => { + const started = ensureReasoning(state, event.id, event.providerMetadata) + const current = started.reasoningParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)), + reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => { + const current = state.reasoningParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)), + reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): ResponseState => ({ + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata }, + }, +}) + +const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } }, + } +} + +const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { + ...current, + name: event.name, + providerMetadata: event.providerMetadata ?? current.providerMetadata, + }, + }, + } +} + +const toolCallContent = (event: ToolCall): ContentPart => + ToolCallPart.make({ + id: event.id, + name: event.name, + input: event.input, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const toolResultContent = (event: ToolResult): ContentPart => + ToolResultPart.make({ + id: event.id, + name: event.name, + result: event.result, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState => { + const { [event.id]: _finished, ...toolInputs } = state.toolInputs + return { ...appendContent(state, toolCallContent(event)), toolInputs } +} + +const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => { + const next = appendEvent(state, event) + switch (event.type) { + case "text-start": + return ensureText(next, event.id, event.providerMetadata) + case "text-delta": + return reduceTextDelta(next, event) + case "text-end": + return reduceTextEnd(next, event) + case "reasoning-start": + return ensureReasoning(next, event.id, event.providerMetadata) + case "reasoning-delta": + return reduceReasoningDelta(next, event) + case "reasoning-end": + return reduceReasoningEnd(next, event) + case "tool-input-start": + return reduceToolInputStart(next, event) + case "tool-input-delta": + return reduceToolInputDelta(next, event) + case "tool-input-end": + return reduceToolInputEnd(next, event) + case "tool-call": + return reduceToolCall(next, event) + case "tool-result": + return appendContent(next, toolResultContent(event)) + default: + return next + } +} + export class LLMResponse extends Schema.Class("LLM.Response")({ + message: Message, events: Schema.Array(LLMEvent), usage: Schema.optional(Usage), + finishReason: FinishReason, }) { /** Concatenated assistant text assembled from streamed `text-delta` events. */ get text() { @@ -356,8 +581,29 @@ export class LLMResponse extends Schema.Class("LLM.Response")({ } export namespace LLMResponse { + export type State = ResponseState export type Output = LLMResponse | { readonly events: ReadonlyArray; readonly usage?: Usage } + /** Initial reducer state for assembling one provider attempt. */ + export const empty = emptyResponseState + + /** Purely fold one provider-neutral event into the attempt assembly state. */ + export const reduce = reduceResponseState + + /** Return a completed response only after a terminal finish or provider error. */ + export const complete = (state: State): LLMResponse | undefined => + state.finishReason === undefined + ? undefined + : new LLMResponse({ + message: state.message, + events: [...state.events], + usage: state.usage, + finishReason: state.finishReason, + }) + + /** Convenience reducer for callers that already have a collected event list. */ + export const fromEvents = (events: ReadonlyArray) => complete(events.reduce(reduce, empty())) + /** Concatenate assistant text from a response or collected event list. */ export const text = (response: Output) => responseText(response.events) diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index 9d98a676e66..c20a6cc2d8d 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -1,4 +1,7 @@ import { Schema } from "effect" +import { ProviderMetadata } from "@opencode-ai/schema/llm" + +export { ProviderMetadata } /** Stable string identifier for a protocol implementation. */ export const ProtocolID = Schema.String @@ -46,6 +49,3 @@ export type FinishReason = Schema.Schema.Type export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) export type JsonSchema = Schema.Schema.Type - -export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)) -export type ProviderMetadata = Schema.Schema.Type diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index ce6687f7ff6..73d64ac5d02 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -1,4 +1,5 @@ import { Schema, SchemaGetter } from "effect" // kilocode_change +import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm" import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options" import { isRecord } from "../utils/record" @@ -39,78 +40,11 @@ export const MediaPart = Schema.Struct({ }).annotate({ identifier: "LLM.Content.Media" }) export type MediaPart = Schema.Schema.Type -export const ToolTextContent = Schema.Struct({ - type: Schema.Literal("text"), - text: Schema.String, -}).annotate({ identifier: "Tool.TextContent" }) -export type ToolTextContent = typeof ToolTextContent.Type +export { ToolContent, ToolFileContent, ToolTextContent } -export const ToolFileContent = Schema.Struct({ - type: Schema.Literal("file"), - uri: Schema.String, - mime: Schema.String, - name: Schema.optional(Schema.String), -}).annotate({ identifier: "Tool.FileContent" }) -export type ToolFileContent = typeof ToolFileContent.Type - -/** Ordered, provider-independent content shown to models and UIs after a tool succeeds. */ -export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]).pipe(Schema.toTaggedUnion("type")) -export type ToolContent = Schema.Schema.Type - -// kilocode_change start - decode persisted V2 tool file shapes and legacy media results -const LegacyToolFileContent = Schema.Struct({ - type: Schema.Literal("file"), - source: Schema.Union([ - Schema.Struct({ type: Schema.Literal("data"), data: Schema.String }), - Schema.Struct({ type: Schema.Literal("url"), url: Schema.String }), - Schema.Struct({ type: Schema.Literal("file"), uri: Schema.String }), - ]).pipe(Schema.toTaggedUnion("type")), - mime: Schema.String, - name: Schema.optional(Schema.String), -}) -const LegacyToolMediaContent = Schema.Struct({ - type: Schema.Literal("media"), - mediaType: Schema.String, - data: Schema.String, - filename: Schema.optional(Schema.String), -}) -const ToolContentInput = Schema.Union([ToolContent, LegacyToolFileContent, LegacyToolMediaContent]) - -const stored = (item: ToolContent): typeof ToolContentInput.Type => { - if (item.type === "text") return item - const data = /^data:[^;,]+;base64,(.*)$/s.exec(item.uri)?.[1] - const source = data - ? ({ type: "data", data } as const) - : URL.canParse(item.uri) && ["http:", "https:"].includes(new URL(item.uri).protocol) - ? ({ type: "url", url: item.uri } as const) - : ({ type: "file", uri: item.uri } as const) - return { type: "file", source, mime: item.mime, name: item.name } -} - -export const StoredToolContent = ToolContentInput.pipe( - Schema.decodeTo(ToolContent, { - decode: SchemaGetter.transform((item) => { - if (item.type === "text" || (item.type === "file" && "uri" in item)) return item - if (item.type === "media") { - return { - type: "file" as const, - uri: item.data.startsWith("data:") ? item.data : `data:${item.mediaType};base64,${item.data}`, - mime: item.mediaType, - name: item.filename, - } - } - const uri = - item.source.type === "data" - ? `data:${item.mime};base64,${item.source.data}` - : item.source.type === "url" - ? item.source.url - : item.source.uri - return { type: "file" as const, uri, mime: item.mime, name: item.name } - }), - encode: SchemaGetter.transform(stored), - }), -) +export { StoredToolContent } from "@opencode-ai/schema/llm" // kilocode_change - shared with the durable event schema +// kilocode_change start - Kilo keeps a tolerant tool-result value union const toolResultValueSchema = Schema.Union([ Schema.Struct({ type: Schema.Literal("json"), value: Schema.Unknown }), Schema.Struct({ type: Schema.Literal("text"), value: Schema.Unknown }), @@ -132,7 +66,7 @@ export const ToolResultValue = Object.assign(toolResultValueSchema, { if (isToolResultValue(value)) return value if (type === "content") return { type, value: Array.isArray(value) ? value : [] } return { type, value } -// kilocode_change end + // kilocode_change end }, }) // kilocode_change diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index c02af6d1ed4..6d11333b536 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -134,15 +134,62 @@ export namespace ModelLimits { input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) } +export class ModelDefaults extends Schema.Class("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 + +export class ModelCompatibility extends Schema.Class("LLM.ModelCompatibility")({ + toolSchema: Schema.optional(ModelToolSchemaCompatibility), +}) {} + +export namespace ModelCompatibility { + export type Input = ModelCompatibility | ConstructorParameters[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 & { + export type Input = Omit & { readonly id: string | ModelID readonly provider: string | ProviderID + readonly defaults?: ModelDefaults.Input + readonly compatibility?: ModelCompatibility.Input } } diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 8e182948fa2..bbbb29f37af 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -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,16 @@ 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"}' }]) }), ) diff --git a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json index 0145756887e..209aadfc1a4 100644 --- a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +++ b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json @@ -21,7 +21,7 @@ "headers": { "content-type": "text/event-stream" }, - "body": "" + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" } }, { @@ -39,7 +39,7 @@ "headers": { "content-type": "text/event-stream" }, - "body": "" + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"cachedContentTokenCount\":1100,\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" } } ] diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json index a3f2e014df4..9d441205fde 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json @@ -22,7 +22,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_output_tokens\":80}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, @@ -40,7 +40,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_output_tokens\":80}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json index 172b8407e60..8aa5dedaaf7 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json @@ -14,7 +14,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json index 21fb8944f4c..4f65f43b576 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json @@ -28,7 +28,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json index 850e381cafb..47670c81272 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json @@ -44,7 +44,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index 633a4662da1..64346a8bb03 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -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" } }) diff --git a/packages/llm/test/prepare.test.ts b/packages/llm/test/prepare.test.ts new file mode 100644 index 00000000000..6923c5a678e --- /dev/null +++ b/packages/llm/test/prepare.test.ts @@ -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( + 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( + LLM.request({ model, prompt: "Say hello.", cache: "none" }), + ) + const withMaxTokens = yield* LLMClient.prepare( + 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) + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index dabf512f6b6..89893129584 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -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", diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index d30742c47d3..1dc253c0ea8 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -347,10 +347,10 @@ describe("Gemini route", () => { { type: "step-start", index: 0 }, { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "reasoning-end", id: "reasoning-0" }, { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, - { type: "reasoning-end", id: "reasoning-0" }, { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { @@ -399,6 +399,9 @@ describe("Gemini route", () => { providerMetadata: { google: { thoughtSignature: "thought_sig" } }, }) expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } }) + expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan( + response.events.findIndex((event) => event.type === "tool-call"), + ) const prepared = yield* LLMClient.prepare( LLM.request({ diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index faf3b5f71b3..268c498b62e 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src" +import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -238,6 +238,27 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { error: { type: "unknown", message: "Tool execution interrupted" } } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }), + ], + }), + ) + + expect(prepared.body.messages.at(-1)).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: ProviderShared.encodeJson(error), + }) + }), + ) + it.effect("continues image tool results as vision input without base64 text", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( @@ -535,9 +556,9 @@ describe("OpenAI Chat route", () => { { type: "step-start", index: 0 }, { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "reasoning-end", id: "reasoning-0" }, { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, - { type: "reasoning-end", id: "reasoning-0" }, { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop" }, { type: "finish", reason: "stop" }, @@ -590,19 +611,22 @@ describe("OpenAI Chat route", () => { }), deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), ) - const response = yield* LLMClient.generate( - LLM.updateRequest(request, { - tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], - }), - ).pipe(Effect.provide(fixedResponse(body))) + const input = LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }) + const events = Array.from( + yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))), + ) + const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip) - expect(response.events).toEqual([ + expect(events).toEqual([ { type: "step-start", index: 0 }, { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, ]) - expect(response.toolCalls).toEqual([]) + expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) + expect(error.message).toContain("Provider stream ended without a terminal finish event") }), ) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 717a7e8024f..cd8bad51af4 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -50,6 +50,7 @@ describe("OpenAI Responses route", () => { { role: "system", content: "You are concise." }, { role: "user", content: [{ type: "input_text", text: "Say hello." }] }, ], + store: false, stream: true, max_output_tokens: 20, temperature: 0, @@ -115,6 +116,7 @@ describe("OpenAI Responses route", () => { type: "function", name: "read", description: "Read a path or resource.", + strict: false, parameters: { type: "object", properties: { @@ -160,16 +162,16 @@ describe("OpenAI Responses route", () => { Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.updateRequest(request, { - model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket( - "gpt-4.1-mini", - ), + model: OpenAIResponses.webSocketRoute + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4.1-mini" }), }), ) expect(prepared.route).toBe("openai-responses-websocket") expect(prepared.protocol).toBe("openai-responses") expect(prepared.metadata).toEqual({ transport: "websocket-json" }) - expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true }) + expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true }) }), ) @@ -355,11 +357,75 @@ describe("OpenAI Responses route", () => { { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' }, { type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' }, ], + store: false, stream: true, + max_output_tokens: undefined, + temperature: undefined, + tool_choice: undefined, + tools: undefined, + top_p: undefined, }) }), ) + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { + error: { type: "unknown", message: "Tool execution interrupted" }, + content: [], + structured: {}, + } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]), + Message.tool({ + id: "call_1", + name: "bash", + resultType: "error", + result: error, + }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error)) + }), + ) + + it.effect("keeps primitive tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("503") + }), + ) + + it.effect("keeps non-JSON tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("Error: boom") + }), + ) + // Regression: screenshot/read tool results must stay structured so base64 // image data is not JSON-stringified into `function_call_output.output`. it.effect("lowers image tool-result content as structured input_image items", () => @@ -460,7 +526,6 @@ describe("OpenAI Responses route", () => { }, { type: "reasoning", - id: "rs_continuation_1", encrypted_content: "encrypted-continuation-state", summary: [{ type: "summary_text", text: "I inspected the previous turn." }], }, @@ -713,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" }, + ]) }), ) @@ -805,7 +875,9 @@ describe("OpenAI Responses route", () => { it.effect("closes reasoning summary parts when storage is not disabled", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }), + ).pipe( Effect.provide( fixedResponse( sseEvents( @@ -866,12 +938,12 @@ describe("OpenAI Responses route", () => { dynamicResponse((input) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(yield* Effect.promise(() => web.json())).toMatchObject({ + const body = yield* Effect.promise(() => web.json()) + expect(body).toMatchObject({ input: [ { role: "user", content: [{ type: "input_text", text: "What changed?" }] }, { type: "reasoning", - id: "rs_1", encrypted_content: "encrypted-state", summary: [{ type: "summary_text", text: "Checked the previous diff." }], }, @@ -879,6 +951,7 @@ describe("OpenAI Responses route", () => { { role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, ], }) + expect(body.input[1]).not.toHaveProperty("id") return input.respond( sseEvents( { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." }, @@ -925,7 +998,6 @@ describe("OpenAI Responses route", () => { { role: "assistant", content: [{ type: "output_text", text: "Before." }] }, { type: "reasoning", - id: "rs_1", encrypted_content: "encrypted-state", summary: [{ type: "summary_text", text: "Checked order." }], }, @@ -1019,7 +1091,6 @@ describe("OpenAI Responses route", () => { expect(prepared.body.input).toEqual([ { type: "reasoning", - id: "rs_1", encrypted_content: "encrypted-state", summary: [ { type: "summary_text", text: "First" }, diff --git a/packages/llm/test/response.test.ts b/packages/llm/test/response.test.ts new file mode 100644 index 00000000000..5e48e5ef457 --- /dev/null +++ b/packages/llm/test/response.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import { LLMEvent, LLMResponse } from "../src" + +const reduce = (events: ReadonlyArray) => events.reduce(LLMResponse.reduce, LLMResponse.empty()) +const finishEvents = (events: ReadonlyArray) => events.filter(LLMEvent.is.finish) + +describe("LLMResponse reducer", () => { + test("assembles interleaved reasoning and text with end metadata", () => { + const events = [ + LLMEvent.reasoningStart({ id: "r1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "I should " }), + LLMEvent.textStart({ id: "t1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "compare..." }), + LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }), + 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", + text: "I should compare...", + providerMetadata: { anthropic: { signature: "sig" } }, + }, + { type: "text", text: "Answer" }, + ]) + }) + + test("preserves partial content without completing a failed stream", () => { + const state = reduce([LLMEvent.textStart({ id: "t1" }), LLMEvent.textDelta({ id: "t1", text: "partial" })]) + + expect(LLMResponse.complete(state)).toBeUndefined() + 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" }), + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query"' }), + ]) + + expect(pending.message.content).toEqual([]) + expect(pending.toolInputs.call_1?.text).toBe('{"query"') + + const response = LLMResponse.fromEvents([ + ...pending.events, + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }), + LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }), + LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }), + LLMEvent.finish({ reason: "tool-calls" }), + ]) + + expect(response?.message.content).toEqual([ + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ]) + }) +}) diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 3a179f4f85d..2457394c9f3 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -628,7 +628,7 @@ describe("LLMClient tools", () => { include: ["reasoning.encrypted_content"], input: [ { role: "user" }, - { type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }, + { type: "reasoning", summary: [], encrypted_content: "encrypted-state" }, { type: "function_call", call_id: "call_1", name: "get_weather" }, { type: "function_call_output", call_id: "call_1" }, ], diff --git a/packages/llm/test/tool-schema-projection.test.ts b/packages/llm/test/tool-schema-projection.test.ts new file mode 100644 index 00000000000..a9df815dafa --- /dev/null +++ b/packages/llm/test/tool-schema-projection.test.ts @@ -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( + 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, + }) + }), + ) +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 87942df31df..af48b9c5fd4 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -166,7 +166,7 @@ "tree-sitter-wasms": "^0.1.12", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "which": "6.0.1", @@ -187,7 +187,9 @@ "xdg-basedir": "5.1.0", "@ff-labs/fff-bun": "0.9.4", "@opencode-ai/tui": "workspace:*", - "@solid-primitives/scheduled": "1.5.2" + "@solid-primitives/scheduled": "1.5.2", + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 948eb3c0633..af8ef761eb2 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { @@ -186,7 +186,7 @@ export class Service extends Context.Service()("@opencode/Ac export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const repo = yield* AccountRepo.Service @@ -456,8 +456,6 @@ export const layer: Layer.Layer()("@opencode/Ac export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -166,8 +166,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) - -export const node = LayerNode.make(layer, [Database.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) export * as AccountRepo from "./repo" diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 222296ff1bc..8c008435ab5 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -33,19 +33,19 @@ export class Org extends Schema.Class("Org")({ export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", { method: Schema.String, url: Schema.String, description: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { return new AccountTransportError({ @@ -92,7 +92,7 @@ export class PollExpired extends Schema.TaggedClass()("PollExpired" export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} export class PollError extends Schema.TaggedClass()("PollError", { - cause: Schema.Defect, + cause: Schema.Defect(), }) {} export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) diff --git a/packages/opencode/src/acp/content.ts b/packages/opencode/src/acp/content.ts index 5f149d85f0f..3b858851acd 100644 --- a/packages/opencode/src/acp/content.ts +++ b/packages/opencode/src/acp/content.ts @@ -1,6 +1,6 @@ import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk" import path from "node:path" -import { pathToFileURL } from "node:url" +import { fileURLToPath, pathToFileURL } from "node:url" import { SessionV1 } from "@opencode-ai/core/v1/session" export type PromptPart = SessionV1.TextPartInput | SessionV1.FilePartInput @@ -76,7 +76,25 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] { case "resource": if ("text" in block.resource) { - return [{ type: "text", text: block.resource.text }] + // kilocode_change - a non-URL uri is the expected fallback; only that is swallowed + const parsed = URL.parse(block.resource.uri) ?? undefined + if (parsed?.protocol === "file:") { + const line = parsed.hash.match(/^#L(\d+)/)?.[1] + let filepath: string + try { + filepath = fileURLToPath(parsed) + } catch { + filepath = decodeURIComponent(parsed.pathname) + } + if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/") + return [ + { + type: "text", + text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}`, + }, + ] + } + return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }] } if (block.resource.mimeType) { return [ diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index f61023c1030..2301c8929a8 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -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" @@ -139,7 +141,7 @@ export const loaderLayer = Layer.effect( }), ) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const loader = yield* Loader @@ -199,12 +201,12 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(loaderLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(InstanceStore.defaultLayer), -) +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" diff --git a/packages/opencode/src/acp/error.ts b/packages/opencode/src/acp/error.ts index 0c66a0e0498..fd7684db9ab 100644 --- a/packages/opencode/src/acp/error.ts +++ b/packages/opencode/src/acp/error.ts @@ -46,6 +46,7 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass()("ACPServiceFailureError", { safeMessage: Schema.String, service: Schema.optional(Schema.String), + errorName: Schema.optional(Schema.String), }) {} export type Error = @@ -81,7 +82,13 @@ export function toRequestError(error: Error) { case "ACPUnsupportedOperationError": return RequestError.methodNotFound(error.method) case "ACPServiceFailureError": - return RequestError.internalError({ service: error.service }, error.safeMessage) + return RequestError.internalError( + { + ...(error.service ? { service: error.service } : {}), + ...(error.errorName ? { errorName: error.errorName } : {}), + }, + error.safeMessage, + ) } } diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index b7630143c17..edca6c903c2 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -1,9 +1,16 @@ -import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk" +import type { + AgentSideConnection, + PermissionOption, + RequestPermissionResponse, + ToolCallContent, + ToolCallLocation, + ToolCallUpdate, +} from "@agentclientprotocol/sdk" import type { Event, KiloClient } from "@kilocode/sdk/v2" import { applyPatch } from "diff" import { exists, readText } from "@/util/filesystem" import type { ACPSession } from "./session" -import { toLocations, toToolKind, type ToolInput } from "./tool" +import { pendingToolCall, toLocations, type ToolInput } from "./tool" import { Effect } from "effect" import { SkillShellPrompt } from "@/kilocode/acp/permission" // kilocode_change @@ -56,14 +63,12 @@ export class Handler { const result = await this.input.connection .requestPermission({ sessionId: permission.sessionID, - toolCall: { + toolCall: await permissionToolCall({ toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: skillShell ? SkillShellPrompt.title : permission.permission, // kilocode_change - rawInput: permission.metadata, // kilocode_change - metadata.commands carries the verbatim command list - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, + toolName: permission.permission, + input: permission.metadata, + title: skillShell ? SkillShellPrompt.title : undefined, // kilocode_change + }), options: skillShell ? SkillShellPrompt.options : permissionOptions, // kilocode_change }) .catch(async () => { @@ -114,6 +119,108 @@ export class Handler { } } +async function permissionToolCall(input: { + readonly toolCallId: string + readonly toolName: string + readonly input: ToolInput + readonly title?: string // kilocode_change - skill-shell batches title the prompt themselves +}): Promise { + const toolCall = pendingToolCall({ + toolCallId: input.toolCallId, + toolName: input.toolName, + state: { + input: input.input, + title: input.title ?? permissionTitle(input.toolName, input.input), // kilocode_change + }, + }) + const content = await permissionContent(input.toolName, input.input) + return { + ...toolCall, + locations: permissionLocations(input.toolName, input.input), + ...(content.length ? { content } : {}), + } +} + +function permissionTitle(toolName: string, input: ToolInput) { + const tool = toolName.toLocaleLowerCase() + switch (tool) { + case "external_directory": + return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir) + + case "webfetch": + return stringValue(input.url) + + case "websearch": + return stringValue(input.query) + + case "grep": + case "glob": + return stringValue(input.pattern) + + case "read": + case "edit": + case "write": + return editTitle(input) + + default: + return undefined + } +} + +function editTitle(input: ToolInput) { + const files = fileMetadata(input) + if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath + if (files.length > 1) return `${files.length} files` + return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path) +} + +function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] { + const files = fileMetadata(input) + if (files.length) { + return Array.from( + new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))), + (path) => ({ path }), + ) + } + return toLocations(toolName, input) +} + +async function permissionContent(toolName: string, input: ToolInput): Promise { + if (toolName.toLocaleLowerCase() !== "edit") return [] + + const files = fileMetadata(input) + if (files.length) return diffContentForFiles(files) + + const filepath = stringValue(input.filepath) ?? stringValue(input.filePath) + const diff = stringValue(input.diff) + if (!filepath || !diff) return [] + const content = await diffContentForPatch(filepath, diff) + return content ? [content] : [] +} + +async function diffContentForFiles(files: PermissionFileMetadata[]) { + const content = await Promise.all( + files.map(async (file) => { + if (!file.patch) return [] + const content = await diffContentForPatch(file.filePath, file.patch, file.movePath) + return content ? [content] : [] + }), + ) + return content.flat() +} + +async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) { + const content = (await exists(filepath)) ? await readText(filepath) : "" + const next = applyPatch(content, diff) + if (next === false) return undefined + return { + type: "diff" as const, + path: displayPath, + oldText: content, + newText: next, + } +} + function selectedReply(result: RequestPermissionResponse): Reply { if (result.outcome.outcome !== "selected") return "reject" if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId @@ -124,4 +231,29 @@ function stringValue(value: unknown) { return typeof value === "string" ? value : undefined } +type PermissionFileMetadata = { + readonly filePath: string + readonly relativePath?: string + readonly movePath?: string + readonly patch?: string +} + +function fileMetadata(input: ToolInput): PermissionFileMetadata[] { + if (!Array.isArray(input.files)) return [] + return input.files.flatMap((file): PermissionFileMetadata[] => { + if (!file || typeof file !== "object") return [] + const info = file as Record + const filePath = stringValue(info.filePath) + if (!filePath) return [] + return [ + { + filePath, + relativePath: stringValue(info.relativePath), + movePath: stringValue(info.movePath), + patch: stringValue(info.patch), + }, + ] + }) +} + export * as ACPPermission from "./permission" diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 6a474aa4298..39ace2961ce 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -30,7 +30,8 @@ import { type SetSessionModeResponse, } from "@agentclientprotocol/sdk" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import type { Message, KiloClient, SessionMessageResponse } from "@kilocode/sdk/v2" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import type { AssistantMessage, Message, KiloClient, SessionMessageResponse } from "@kilocode/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPError from "./error" import { buildConfigOptions, parseModelSelection } from "./config-option" @@ -316,7 +317,6 @@ export function make(input: { yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) yield* sendAvailableCommands(input.connection, state.id, snapshot) - yield* replayMessages(events, messages) return { configOptions: configOptions(snapshot, { @@ -523,7 +523,7 @@ export function make(input: { "session", ) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) - return promptResponse(response.info, params.messageId) + return yield* promptResponse(response.info, params.messageId) } const known = snapshot.availableCommands.find((item) => item.name === command.name) @@ -545,7 +545,7 @@ export function make(input: { "session", ) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) - return promptResponse(response.info, params.messageId) + return yield* promptResponse(response.info, params.messageId) } if (command.name === "compact") { @@ -565,30 +565,31 @@ export function make(input: { } yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) - return promptResponse(undefined, params.messageId) + return yield* promptResponse(undefined, params.messageId) }), cancel, } } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(AppNodeBuilder.build(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } function makeDirectoryService(sdk: KiloClient) { return ManagedRuntime.make( - Directory.layer.pipe( - Layer.provide( + AppNodeBuilder.build(Directory.node, [ + [ + Directory.loaderNode, Layer.succeed( Directory.Loader, Directory.Loader.of({ load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), }), ), - ), - ), + ], + ]), ).runSync(Directory.Service.use((service) => Effect.succeed(service))) } @@ -697,7 +698,8 @@ type MessageInfo = { readonly agent?: Message["agent"] } -type AssistantInfo = UsageService.AssistantTokenCost | undefined +type AssistantError = NonNullable +type AssistantInfo = (UsageService.AssistantTokenCost & Pick) | undefined function request(fn: () => Promise>, service?: string) { return Effect.tryPromise({ @@ -813,13 +815,60 @@ function detectSlashCommand(parts: ReturnType) { return { name, args: rest.join(" ").trim() } } -function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse { - return { - stopReason: "end_turn", - ...(info ? { usage: UsageService.buildUsage(info) } : {}), +const promptResponse = Effect.fn("ACP.promptResponse")(function* ( + info: AssistantInfo, + messageId: string | null | undefined, +) { + if (!info?.error) { + return { + stopReason: "end_turn" as const, + ...(info ? { usage: UsageService.buildUsage(info) } : {}), + ...(messageId ? { userMessageId: messageId } : {}), + _meta: {}, + } + } + + const base = { + usage: UsageService.buildUsage(info), ...(messageId ? { userMessageId: messageId } : {}), _meta: {}, } + + if (info.error.name === "MessageAbortedError") { + return { + stopReason: "cancelled" as const, + ...base, + } + } + + if (info.error.name === "MessageOutputLengthError") { + return { + stopReason: "max_tokens" as const, + ...base, + } + } + + if (info.error.name === "ContentFilterError") { + return { + stopReason: "refusal" as const, + ...base, + } + } + + if (info.error.name === "ProviderAuthError") { + return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID }) + } + + return yield* new ACPError.ServiceFailureError({ + service: "session", + safeMessage: promptErrorMessage(info.error), + errorName: info.error.name, + }) +}) + +function promptErrorMessage(error: AssistantError) { + if ("message" in error.data && typeof error.data.message === "string") return error.data.message + return "Kilo prompt failed" // kilocode_change - user-visible ACP error } function sendUsageUpdate( diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 514f6812bf2..05a0919f14f 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,5 +1,6 @@ import type { McpServer } from "@agentclientprotocol/sdk" import type { Message, Part } from "@kilocode/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" @@ -93,7 +94,7 @@ export class Service extends Context.Service()("@opencode/AC type State = Map -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Ref.make(new Map()) @@ -200,7 +201,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const node = LayerNode.make({ service: Service, layer, deps: [] }) function makeSession(input: StoreInput): Info { return { diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts index d0e57cc2ecf..309c126d6e9 100644 --- a/packages/opencode/src/acp/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -192,11 +192,8 @@ export function completedToolUpdate(input: { return { toolCallId: input.toolCallId, status: "completed", - kind: toToolKind(input.toolName), - title: toolTitle(input.toolName, input.state.input, input.state.title), - locations: toLocations(input.toolName, input.state.input, input.cwd), + ...(input.state.title ? { title: input.state.title } : {}), content: completedToolContent(input.toolName, input.state), - rawInput: rawInput(input.toolName, input.state.input, input.cwd), rawOutput: completedToolRawOutput(input.state), } } @@ -266,7 +263,7 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { // For shell tools, surface the actual command as the title so it stays visible // before output lands; non-shell tools keep their model-provided title. function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) { - if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName + if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName return fallback || toolName } diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index 1f110f38937..b13a7fe1e75 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -1,7 +1,10 @@ import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk" import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@kilocode/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" @@ -132,7 +135,7 @@ export const contextLimitLoaderLayer = Layer.effect( }), ) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const messageLoader = yield* MessageLoader @@ -223,10 +226,14 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(contextLimitLoaderLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(InstanceStore.defaultLayer), -) +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" diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b3df2c7ae52..a80f81cd8b4 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -36,10 +36,10 @@ import { MCP } from "@/mcp" // kilocode_change end import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" +import { PluginV2 } from "@opencode-ai/core/plugin" export type RequirementBlockedError = InstanceType // kilocode_change @@ -106,7 +106,7 @@ export class Service extends Context.Service()("@opencode/Ag export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -116,7 +116,7 @@ export const layer = Layer.effect( const mcp = yield* MCP.Service // kilocode_change const provider = yield* Provider.Service const flags = yield* RuntimeFlags.Service // kilocode_change - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const state = yield* InstanceState.make( Effect.fn("Agent.state")(function* (ctx) { @@ -124,7 +124,9 @@ export const layer = Layer.effect( const skillDirs = yield* skill.dirs() // kilocode_change start - include global config dirs so agents can read them without prompting const referenceDirs = yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() + if (Object.keys(cfg.references ?? cfg.reference ?? {}).length) { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) + } yield* KiloReference.sync({ references: cfg.references ?? cfg.reference ?? {}, directory: ctx.directory, @@ -641,34 +643,16 @@ export const layer = Layer.effect( }), ) -// kilocode_change start - preserve the concrete layer type across Kilo's Agent/Skill cycle -export const defaultLayer: Layer.Layer = layer.pipe( - // kilocode_change end - Layer.provide(Plugin.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), - // kilocode_change start - Layer.provide(MCP.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - // kilocode_change end - Layer.provide(LocationServiceMap.layer), -) +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) -const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, []) - -export const node = LayerNode.make(layer, [ - Config.node, - Auth.node, - Plugin.node, - Skill.node, - Provider.node, - // kilocode_change start - MCP.node, - RuntimeFlags.node, - // kilocode_change end - locationServiceMapNode, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, MCP.node, RuntimeFlags.node, locationServiceMapNode], // kilocode_change +}) export * as Agent from "./agent" diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index da03977db9b..b18273a1147 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -38,7 +38,7 @@ export type Info = Schema.Schema.Type export class AuthError extends Schema.TaggedErrorClass()("AuthError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export interface Interface { @@ -50,7 +50,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Auth") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fsys = yield* FSUtil.Service @@ -100,8 +100,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer)) - -export const node = LayerNode.make(layer, [FSUtil.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] }) +export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer)) // kilocode_change - legacy Kilo runtime compatibility export * as Auth from "." diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/background/job.ts index f3511676d7f..261f5ad02be 100644 --- a/packages/opencode/src/background/job.ts +++ b/packages/opencode/src/background/job.ts @@ -15,7 +15,7 @@ export { } from "@opencode-ai/core/background-job" /** Keeps the legacy service instance-scoped while sharing the core registry engine. */ -export const layer = Layer.effect( +const layer = Layer.effect( CoreBackgroundJob.Service, Effect.gen(function* () { const state = yield* InstanceState.make(() => CoreBackgroundJob.make) @@ -32,8 +32,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer - -export const node = LayerNode.make(layer, []) +export const node = LayerNode.make({ service: CoreBackgroundJob.Service, layer, deps: [] }) export * as BackgroundJob from "./job" diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 5a9e52ef073..fa42f5b8ce0 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { EventManifest } from "@/event-manifest" // kilocode_change export type Definition = { type: Type @@ -29,8 +29,8 @@ export function effectPayloads() { }).annotate({ identifier: `Event.${type}` }), ) .toArray(), - ...EventV2.registry - .values() + // kilocode_change start - expose current Effect events through legacy bus schemas + ...EventManifest.Latest.values() .map((definition) => Schema.Struct({ id: Schema.String, @@ -39,6 +39,7 @@ export function effectPayloads() { }).annotate({ identifier: `Event.${definition.type}` }), ) .toArray(), + // kilocode_change end ] } diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 69a6fdcb284..300a5c5d6d4 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -194,7 +194,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer -export const node = LayerNode.make(layer, []) // kilocode_change +export const node = LayerNode.make({ service: Service, layer, deps: [] }) // kilocode_change const { runPromise } = makeRuntime(Service, layer) // kilocode_change export function createID() { diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 3c1810be7ce..0fe0e687c14 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -12,6 +12,7 @@ export const AttachCommand = cmd({ command: "attach ", describe: "attach to a running kilo server", // kilocode_change builder: (yargs) => + yargs .positional("url", { type: "string", @@ -49,14 +50,31 @@ export const AttachCommand = cmd({ alias: ["u"], type: "string", describe: "basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo')", // kilocode_change + }) + .option("mini", { + type: "boolean", + describe: "start the minimal interactive interface", + default: false, + }) + .option("replay", { + type: "boolean", + hidden: true, + }) + .option("no-replay", { + type: "boolean", + describe: "disable mini session history replay on resume and after resize", + }) + .option("replay-limit", { + type: "number", + describe: "cap visible mini replay to the newest N messages", }), handler: async (args) => { - const { TuiConfig } = await import("@/config/tui") - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") + if (args.replay === true) { + UI.error("--replay is not supported; replay is enabled by default") process.exitCode = 1 return } + const noReplay = args.replay === false || args.noReplay === true // kilocode_change start const { importCloudSession, validateCloudFork } = await import("@/kilocode/cloud-session") @@ -78,6 +96,40 @@ export const AttachCommand = cmd({ return args.dir } })() + + if (args.mini) { + const { runMini } = await import("./run") + await runMini({ + attach: args.url, + directory, + password: args.password, + username: args.username, + continue: args.continue, + session: args.session, + fork: args.fork, + replay: noReplay ? false : undefined, + replayLimit: args.replayLimit, + }) + return + } + + const unsupported = [ + ["--no-replay", noReplay], + ["--replay-limit", args.replayLimit !== undefined], + ].find((entry) => entry[1])?.[0] + if (unsupported) { + UI.error(`${unsupported} requires --mini`) + process.exitCode = 1 + return + } + + const { TuiConfig } = await import("@/config/tui") + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exitCode = 1 + return + } + const headers = ServerAuth.headers({ password: args.password, username: args.username }) // kilocode_change start - import cloud session before TUI renders if (args.cloudFork && args.session) { diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index 05af009b884..910787f9407 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -export type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[]; _?: Array } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 0e6ef98c246..02383aeaf96 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,7 +1,7 @@ import { EOL } from "os" import { Effect } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -9,8 +9,8 @@ import { cmd } from "../cmd" const filesystem = (effect: Effect.Effect) => effect.pipe( - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))), - Effect.provide(LocationServiceMap.layer), + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))), + Effect.provide(locationServiceMapLayer), ) const FileSearchCommand = effectCmd({ diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index eef6e055696..f624ee882e4 100644 --- a/packages/opencode/src/cli/cmd/debug/scrap.ts +++ b/packages/opencode/src/cli/cmd/debug/scrap.ts @@ -7,8 +7,9 @@ export const ScrapCommand = cmd({ builder: (yargs) => yargs, async handler() { const { Project } = await import("@/project/project") + const { AppNodeBuilder } = await import("@opencode-ai/core/effect/app-node-builder") const { makeRuntime } = await import("@opencode-ai/core/effect/runtime") - const runtime = makeRuntime(Project.Service, Project.defaultLayer) + const runtime = makeRuntime(Project.Service, AppNodeBuilder.build(Project.node)) const list = await runtime.runPromise((project) => project.list()) process.stdout.write(JSON.stringify(list, null, 2) + EOL) }, diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 74288529d4c..c1f82bb79fe 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -1,9 +1,8 @@ import { EOL } from "os" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -13,22 +12,16 @@ export const V2Command = effectCmd({ instance: false, handler: () => Effect.gen(function* () { - yield* PluginBoot.Service.use((service) => service.wait()) const catalog = yield* Catalog.Service const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id)) const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id)) const result = { providers, - default: catalog.model - .default() - .pipe(Effect.map(Option.map((item) => item.id)), Effect.map(Option.getOrUndefined)), + default: catalog.model.default().pipe(Effect.map((item) => item?.id)), small: Object.fromEntries( yield* Effect.all( all.map((provider) => - Effect.map( - catalog.model.small(provider.id), - (model) => [provider.id, Option.getOrUndefined(Option.map(model, (item) => item.id))] as const, - ), + Effect.map(catalog.model.small(provider.id), (model) => [provider.id, model?.id] as const), ), { concurrency: "unbounded" }, ), @@ -38,12 +31,12 @@ export const V2Command = effectCmd({ }).pipe( Effect.withSpan("Cli.debug.v2"), Effect.provide( - LocationServiceMap.get( + LocationServiceMap.Service.get( Location.Ref.make({ directory: AbsolutePath.make(process.cwd()), }), ), ), - Effect.provide(LocationServiceMap.layer), + Effect.provide(locationServiceMapLayer), ), }) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 02d1fea5878..5e085a7ef11 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -20,8 +20,6 @@ import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" // kilocode_change - KilocodeMcpConfig is dynamically imported in addMcpToConfig to keep startup fast import { Filesystem } from "@/util/filesystem" -import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change @@ -260,21 +258,13 @@ export const McpAuthCommand = effectCmd({ const spinner = prompts.spinner() spinner.start("Starting OAuth flow...") - // Subscribe to browser open failure events to show URL for manual opening - const events = yield* EventV2Bridge.Service - const unsubscribe = yield* events.listen((event) => { - if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void - const data = event.data as EventV2.Data - if (data.mcpName === serverName) { - spinner.stop("Could not open browser automatically") - prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(data.url) + yield* MCP.Service.use((mcp) => + mcp.authenticate(serverName, (url) => { + spinner.stop("Authorize in your browser:") + prompts.log.info(url) spinner.start("Waiting for authorization...") - } - return Effect.void - }) - - yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( + }), + ).pipe( Effect.tap((status) => Effect.sync(() => { if (status.status === "connected") { @@ -309,7 +299,6 @@ export const McpAuthCommand = effectCmd({ prompts.log.error(error instanceof Error ? error.message : String(error)) }), ), - Effect.ensuring(unsubscribe), ) prompts.outro("Done") @@ -701,14 +690,20 @@ export const McpDebugCommand = effectCmd({ const config = yield* Config.Service.use((cfg) => cfg.get()) const mcp = yield* MCP.Service const auth = yield* McpAuth.Service + const serverConfig = config.mcp?.[args.name] + const authInfo = + serverConfig && isMcpRemote(serverConfig) && serverConfig.oauth !== false + ? yield* Effect.all({ + authStatus: mcp.getAuthStatus(args.name), + entry: auth.get(args.name), + }) + : undefined yield* Effect.promise(async () => { UI.empty() prompts.intro("MCP OAuth Debug") - const mcpServers = config.mcp ?? {} const serverName = args.name - const serverConfig = mcpServers[serverName] if (!serverConfig) { prompts.log.error(`MCP server not found: ${serverName}`) prompts.outro("Done") @@ -730,17 +725,13 @@ export const McpDebugCommand = effectCmd({ prompts.log.info(`Server: ${serverName}`) prompts.log.info(`URL: ${serverConfig.url}`) - // Check stored auth status — services already in hand, run inline. - const { authStatus, entry } = await Effect.runPromise( - Effect.all({ - authStatus: mcp.getAuthStatus(serverName), - entry: auth.get(serverName), - }), - ) + const { authStatus, entry } = authInfo! prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) + prompts.log.info( + ` Access token: ${entry.tokens.accessToken.length > 8 ? `${entry.tokens.accessToken.slice(0, 4)}***${entry.tokens.accessToken.slice(-4)}` : "***"}`, + ) if (entry.tokens.expiresAt) { const expiresDate = new Date(entry.tokens.expiresAt * 1000) const isExpired = entry.tokens.expiresAt < Date.now() / 1000 @@ -791,7 +782,7 @@ export const McpDebugCommand = effectCmd({ } if (response.status === 401) { - prompts.log.warn("Server returned 401 Unauthorized") + prompts.log.info("Initial unauthenticated check returned 401, so this server requires OAuth") // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 580cbc93ea3..1ae73d53c62 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,13 +1,14 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { FSUtil } from "@opencode-ai/core/fs-util" // kilocode_change start - use Kilo CLI branding -// CLI entry point for `kilo run`. +// CLI entry point for `kilo run` and `kilo --mini`. // // Handles three modes: // 1. Non-interactive (default): sends a single prompt, streams events to // stdout, and exits when the session goes idle. -// 2. Interactive local (`--interactive`): boots the split-footer direct mode +// 2. Interactive local (`kilo --mini`): boots the split-footer direct mode // with an in-process server (no external HTTP). -// 3. Interactive attach (`--interactive --attach`): connects to a running +// 3. Interactive attach (`kilo --mini --attach`): connects to a running // kilo server and runs interactive mode against it. // kilocode_change end // @@ -17,6 +18,7 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" +import { open } from "node:fs/promises" import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" @@ -60,6 +62,8 @@ type FilePart = { mime: string } +const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 + type Inline = { icon: string title: string @@ -226,13 +230,20 @@ export const RunCommand = effectCmd({ type: "boolean", describe: "show thinking blocks", }) + .option("mini", { + type: "boolean", + hidden: true, + default: false, + }) .option("replay", { type: "boolean", default: true, + hidden: true, describe: "replay interactive session history on resume and after resize (use --no-replay to disable)", }) .option("replay-limit", { type: "number", + hidden: true, describe: "cap visible interactive replay to the newest N messages", }) .option("interactive", { @@ -241,21 +252,25 @@ export const RunCommand = effectCmd({ describe: "run in direct interactive split-footer mode", default: false, }) - .option("dangerously-skip-permissions", { + .option("auto", { type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, }) - // kilocode_change start - auto approve tracked task sessions - .option("auto", { + .option("yolo", { type: "boolean", - describe: "auto-approve all permissions (for autonomous/pipeline usage)", + hidden: true, + default: false, + }) + .option("dangerously-skip-permissions", { + type: "boolean", + hidden: true, default: false, }) - // kilocode_change end .option("demo", { type: "boolean", default: false, + hidden: true, describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { @@ -276,7 +291,9 @@ export const RunCommand = effectCmd({ const localInstance = yield* InstanceRef yield* Effect.promise(async () => { const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") - const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) + const interactive = args.mini || args.interactive // kilocode_change - retain `kilo run --interactive` + const skipPermissions = args.yolo || args["dangerously-skip-permissions"] // kilocode_change - --auto is answered by the tracked-session block below + const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false) const die = (message: string): never => { UI.error(message) process.exit(1) @@ -291,20 +308,24 @@ export const RunCommand = effectCmd({ let message = buildRunMessage(args.message, args["--"]) // kilocode_change - if (args.interactive && args.command) { - die("--interactive cannot be used with --command") + if (interactive && args.command) { + die("--mini cannot be used with --command") } - if (args.demo && !args.interactive) { - die("--demo requires --interactive") + if (args.mini && args._?.[0] !== "mini") { + die("--mini must be used without the run subcommand") } - if (args.interactive && args.format === "json") { - die("--interactive cannot be used with --format json") + if (args.demo && !interactive) { + die("--demo requires --mini") } - if (args["replay-limit"] !== undefined && !args.interactive) { - die("--replay-limit requires --interactive") + if (interactive && args.format === "json") { + die("--mini cannot be used with --format json") + } + + if (args["replay-limit"] !== undefined && !interactive) { + die("--replay-limit requires --mini") } if ( @@ -314,11 +335,11 @@ export const RunCommand = effectCmd({ die("--replay-limit must be a positive integer") } - if (args.interactive && !process.stdout.isTTY) { - die("--interactive requires a TTY stdout") + if (interactive && !process.stdout.isTTY) { + die("--mini requires a TTY stdout") } - if (args.interactive) { + if (interactive) { try { resolveInteractiveStdin().cleanup?.() } catch (error) { @@ -326,7 +347,7 @@ export const RunCommand = effectCmd({ } } - const replay = args.replay || args["replay-limit"] !== undefined + const replay = args.replay === false ? false : args.replay || args["replay-limit"] !== undefined const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) const directory = (() => { @@ -363,11 +384,48 @@ export const RunCommand = effectCmd({ process.exit(1) } - const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + const stat = Filesystem.stat(resolvedPath) + const isDirectory = stat?.isDirectory() ?? false + if (args.attach && isDirectory) { + UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`) + process.exit(1) + } + + const content = await (async () => { + if (!args.attach) return + const handle = await open(resolvedPath, "r") + try { + const opened = await handle.stat() + if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) { + UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`) + process.exit(1) + } + if (opened.size === 0) return Buffer.alloc(0) + const buffer = Buffer.alloc(Number(opened.size)) + let offset = 0 + while (offset < buffer.length) { + const read = await handle.read(buffer, offset, buffer.length - offset, offset) + if (read.bytesRead === 0) break + offset += read.bytesRead + } + return buffer.subarray(0, offset) + } finally { + await handle.close() + } + })() + const detected = FSUtil.mimeType(resolvedPath) + const text = content?.toString("utf8") + const mime = !args.attach + ? isDirectory + ? "application/x-directory" + : "text/plain" + : content && text !== undefined && Buffer.from(text, "utf8").equals(content) + ? "text/plain" + : detected files.push({ type: "file", - url: pathToFileURL(resolvedPath).href, + url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href, filename: path.basename(resolvedPath), mime, }) @@ -382,7 +440,7 @@ export const RunCommand = effectCmd({ message = resolveRunInput(message, piped) ?? "" input.initial = resolveRunInput(rawMessage, piped) input.loaded = true - if (message.trim().length > 0 || args.command || args.interactive) return + if (message.trim().length > 0 || args.command || interactive) return UI.error("You must provide a message or a command") process.exit(1) } @@ -406,7 +464,7 @@ export const RunCommand = effectCmd({ } // kilocode_change end - const rules: PermissionV1.Ruleset = args.interactive + const rules: PermissionV1.Ruleset = interactive ? [] : [ { @@ -703,8 +761,8 @@ export const RunCommand = effectCmd({ } const sessionID = sess.id // kilocode_change start - track Task children; plain headless runs deny subagent asks instead of hanging (#11903) - const auto = KiloRunAuto.create(sessionID) - if (!args.attach && !args.auto && !args["dangerously-skip-permissions"]) KiloHeadless.mark(sessionID) + const tracked = KiloRunAuto.create(sessionID) // kilocode_change - named to avoid shadowing the `auto` flag + if (!args.attach && !args.auto && !skipPermissions) KiloHeadless.mark(sessionID) // kilocode_change - --yolo skips too // kilocode_change end function emit(type: string, data: Record) { @@ -753,7 +811,7 @@ export const RunCommand = effectCmd({ if (event.type === "message.part.updated") { const part = event.properties.part // kilocode_change start - track Task child sessions so permission replies can target them - KiloRunAuto.track(auto, part) + KiloRunAuto.track(tracked, part) // kilocode_change end if (part.sessionID !== sessionID) continue @@ -857,7 +915,7 @@ export const RunCommand = effectCmd({ // kilocode_change end // kilocode_change start - approve root and tracked Task child permissions in auto mode if (args.auto) { - if (!KiloRunAuto.allowed(auto, permission.sessionID)) continue + if (!KiloRunAuto.allowed(tracked, permission.sessionID)) continue await client.permission.reply({ requestID: permission.id, reply: "once", @@ -870,8 +928,8 @@ export const RunCommand = effectCmd({ // Covers daemon/attach modes where the server evaluates permissions in another // process and the in-process KiloHeadless deny cannot apply. if (permission.sessionID !== sessionID) { - if (!KiloRunAuto.allowed(auto, permission.sessionID)) continue - if (args["dangerously-skip-permissions"]) { + if (!KiloRunAuto.allowed(tracked, permission.sessionID)) continue + if (skipPermissions) { await client.permission.reply({ requestID: permission.id, reply: "once", @@ -894,7 +952,7 @@ export const RunCommand = effectCmd({ if (permission.sessionID !== sessionID) continue - if (args["dangerously-skip-permissions"]) { + if (skipPermissions) { await client.permission.reply({ requestID: permission.id, reply: "once", @@ -957,7 +1015,7 @@ export const RunCommand = effectCmd({ await share(client, sessionID) - if (!args.interactive) { + if (!interactive) { const events = await client.event.subscribe() const completed = loop(client, events).catch((e) => { console.error(e) @@ -1042,7 +1100,7 @@ export const RunCommand = effectCmd({ return } - if (args.interactive && !args.attach && !args.session && !args.continue) { + if (interactive && !args.attach && !args.session && !args.continue) { await loadInput() // kilocode_change - interactive local mode still consumes its initial input const model = pick(args.model) const { runInteractiveLocalMode } = await import("./run/runtime") @@ -1103,3 +1161,57 @@ export const RunCommand = effectCmd({ }) }), }) + +type MiniCommandInput = { + directory?: string + attach?: string + password?: string + username?: string + continue?: boolean + session?: string + fork?: boolean + model?: string + agent?: string + prompt?: string + replay?: boolean + replayLimit?: number + demo?: boolean +} + +export async function runMini(input: MiniCommandInput) { + if (!RunCommand.handler) throw new Error("Mini command handler is unavailable") + await RunCommand.handler({ + $0: "opencode", + _: ["mini"], + message: input.prompt ? [input.prompt] : [], + command: undefined, + continue: input.continue, + session: input.session, + fork: input.fork, + "cloud-fork": undefined, // kilocode_change + cloudFork: undefined, // kilocode_change + share: undefined, + model: input.model, + agent: input.agent, + format: "default", + file: undefined, + title: undefined, + attach: input.attach, + password: input.password, + username: input.username, + dir: input.directory, + port: undefined, + variant: undefined, + thinking: undefined, + mini: true, + interactive: false, + replay: input.replay ?? true, + "replay-limit": input.replayLimit, + replayLimit: input.replayLimit, + auto: false, + yolo: false, + "dangerously-skip-permissions": false, + dangerouslySkipPermissions: false, + demo: input.demo ?? false, + }) +} diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts index dad46a7fb02..d236fb02c2e 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts @@ -1,7 +1,7 @@ import fs from "fs" import * as tty from "node:tty" -export const INTERACTIVE_INPUT_ERROR = "--interactive requires a controlling terminal for input" +export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input" type InteractiveStdin = { stdin: NodeJS.ReadStream diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 2562f1fcb0d..27361a6727a 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -1,4 +1,4 @@ -// Top-level orchestrator for `run --interactive`. +// Top-level orchestrator for `opencode --mini`. // // Wires the boot sequence, lifecycle (renderer + footer), stream transport, // and prompt queue together into a single session loop. Two entry points: diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index e64ab936884..e0d95e773be 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -652,20 +652,18 @@ function snapQuestion(p: ToolProps): ToolSnapshot { function scrollBashStart(p: ToolProps): string { const cmd = p.input.command ?? "" - const desc = p.input.description || "Shell" const wd = p.input.workdir ?? "" - const dir = wd && wd !== "." ? toolPath(wd) : "" - if (cmd && desc === "Shell" && !dir) { + const formatted = wd && wd !== "." ? toolPath(wd) : "" + const dir = formatted === "." ? "" : formatted + if (cmd && !dir) { return `$ ${cmd}` } - const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc - if (!cmd) { - return `# ${title}` + return dir ? `# Running in ${dir}` : "" } - return `# ${title}\n$ ${cmd}` + return `# Running in ${dir}\n$ ${cmd}` } function scrollBashProgress(p: ToolProps): string { @@ -997,11 +995,10 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo { } function permBash(p: ToolPermissionProps): ToolPermissionInfo { - const title = p.input.description || "Shell command" const cmd = p.input.command || "" return { icon: "#", - title, + title: "Shell command", lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`), } } diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 81c7d300f80..c9aebc2858c 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -1,4 +1,4 @@ -// Shared type vocabulary for the direct interactive mode (`run --interactive`). +// Shared type vocabulary for the direct interactive mode (`opencode --mini`). // // Direct mode uses a split-footer terminal layout: immutable scrollback for the // session transcript, and a mutable footer for prompt input, status, and diff --git a/packages/opencode/src/cli/cmd/run/variant.shared.ts b/packages/opencode/src/cli/cmd/run/variant.shared.ts index 43f17b43db1..846569179ad 100644 --- a/packages/opencode/src/cli/cmd/run/variant.shared.ts +++ b/packages/opencode/src/cli/cmd/run/variant.shared.ts @@ -8,6 +8,7 @@ // variant and the persisted file. import path from "path" import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Context, Effect, Layer } from "effect" import { makeRuntime } from "@/effect/run-service" import { Global } from "@opencode-ai/core/global" @@ -135,7 +136,7 @@ function state(value: unknown): ModelState { } } -function createLayer(fs = FSUtil.defaultLayer) { +function createLayer(fs = AppNodeBuilder.build(FSUtil.node)) { return Layer.fresh( Layer.effect( Service, @@ -196,7 +197,7 @@ function createLayer(fs = FSUtil.defaultLayer) { } /** @internal Exported for testing. */ -export function createVariantRuntime(fs = FSUtil.defaultLayer): VariantRuntime { +export function createVariantRuntime(fs = AppNodeBuilder.build(FSUtil.node)): VariantRuntime { const runtime = makeRuntime(Service, createLayer(fs)) return { resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined), diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index a02a468b470..6258904e8c9 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from "url" import { UI } from "@/cli/ui" import { errorMessage } from "@opencode-ai/tui/util/error" import { withTimeout } from "@/util/timeout" -import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" +import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli/network" import { Filesystem } from "@/util/filesystem" import type { GlobalEvent } from "@kilocode/sdk/v2" import type { EventSource } from "@opencode-ai/tui/context/sdk" @@ -175,8 +175,88 @@ export const TuiThreadCommand = cmd({ .option("agent", { type: "string", describe: "agent to use", + }) + .option("auto", { + type: "boolean", + describe: "auto-approve permissions that are not explicitly denied (dangerous!)", + default: false, + }) + .option("yolo", { + type: "boolean", + hidden: true, + default: false, + }) + .option("dangerously-skip-permissions", { + type: "boolean", + hidden: true, + default: false, + }) + .option("mini", { + type: "boolean", + describe: "start the minimal interactive interface", + default: false, + }) + .option("replay", { + type: "boolean", + hidden: true, + }) + .option("no-replay", { + type: "boolean", + describe: "disable mini session history replay on resume and after resize", + }) + .option("replay-limit", { + type: "number", + describe: "cap visible mini replay to the newest N messages", + }) + .option("demo", { + type: "boolean", + hidden: true, }), handler: async (args) => { + if (args.replay === true) { + UI.error("--replay is not supported; replay is enabled by default") + process.exitCode = 1 + return + } + const noReplay = args.replay === false || args.noReplay === true + + if (args.mini) { + const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) => + process.argv.some((arg) => arg === option || arg.startsWith(option + "=")), + ) + if (network) { + UI.error(`${network} cannot be used with --mini`) + process.exitCode = 1 + return + } + + const { runMini } = await import("./run") + await runMini({ + directory: resolveThreadDirectory(args.project), + continue: args.continue, + session: args.session, + fork: args.fork, + model: args.model, + agent: args.agent, + prompt: args.prompt, + replay: noReplay ? false : undefined, + replayLimit: args.replayLimit, + demo: args.demo, + }) + return + } + + const unsupported = [ + ["--no-replay", noReplay], + ["--replay-limit", args.replayLimit !== undefined], + ["--demo", args.demo !== undefined], + ].find((entry) => entry[1])?.[0] + if (unsupported) { + UI.error(`${unsupported} requires --mini`) + process.exitCode = 1 + return + } + // kilocode_change start - lazy Kilo implementations so other CLI commands // don't pay their module cost at startup const { importCloudSession, localSessionID, validateCloudFork } = await import("@/kilocode/cloud-session") @@ -327,13 +407,7 @@ export const TuiThreadCommand = cmd({ const config = await TuiConfig.get() const network = resolveNetworkOptionsNoConfig(args) - const external = - process.argv.includes("--port") || - process.argv.includes("--hostname") || - process.argv.includes("--mdns") || - network.mdns || - network.port !== 0 || - network.hostname !== "127.0.0.1" + const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true const transport = external ? { @@ -349,6 +423,8 @@ export const TuiThreadCommand = cmd({ events: createEventSource(client), } + // kilocode_change - upstream validates here, but --cloud-fork's session id is only local after + // the import below; the guarded validateSession further down covers both paths. setTimeout(() => { client.call("checkUpgrade", { directory: cwd }).catch((err) => console.error("Upgrade check failed", err)) }, 1000).unref?.() @@ -402,7 +478,7 @@ export const TuiThreadCommand = cmd({ config, directory: cwd, fetch: transport.fetch, - headers: transport.headers, // kilocode_change + headers: transport.headers, events: transport.events, args: { continue: args.continue, @@ -411,6 +487,7 @@ export const TuiThreadCommand = cmd({ model: args.model, prompt, fork: args.fork, + auto: args.auto || args.yolo || args["dangerously-skip-permissions"], }, }, embeddedRemoteExitClient(external, client), diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index ddc48d3a71f..b47a93acde4 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -58,6 +58,16 @@ export function explicitNetworkOptions(argv = process.argv) { export function withNetworkOptions(yargs: Argv) { return yargs.options(options) } + +export function hasArg(name: string) { + return networkArgs().some((arg) => arg === name || arg.startsWith(name + "=")) +} + +function networkArgs() { + const separator = process.argv.indexOf("--") + return process.argv.slice(2, separator === -1 ? undefined : separator) +} + export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(function* (args: NetworkOptions) { const { Config } = yield* Effect.promise(() => import("@/config/config")) const config = yield* Config.Service.use((cfg) => cfg.getGlobal()) diff --git a/packages/opencode/src/cli/tui/layer.ts b/packages/opencode/src/cli/tui/layer.ts index cc88498cb98..a592b86daeb 100644 --- a/packages/opencode/src/cli/tui/layer.ts +++ b/packages/opencode/src/cli/tui/layer.ts @@ -1,7 +1,8 @@ import { run as runTui, type TuiInput } from "@opencode-ai/tui" import { Global } from "@opencode-ai/core/global" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect } from "effect" export function run(input: TuiInput) { - return runTui(input).pipe(Effect.provide(Global.defaultLayer)) + return runTui(input).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) } diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 5232ed0b87c..0e2fcaaef6f 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -20,6 +20,19 @@ ensureProcessMetadata("worker") // kilocode_change - retain worker role and pare await KiloLog.init() // kilocode_change - keep compatibility logs off the TUI terminal Heap.start() +// kilocode_change start - keep upstream's keep-alive intent but never swallow the error silently +const onUnhandledRejection = (error: unknown) => { + console.error("worker unhandledRejection", error) +} + +const onUncaughtException = (error: Error) => { + console.error("worker uncaughtException", error) +} +// kilocode_change end + +process.on("unhandledRejection", onUnhandledRejection) +process.on("uncaughtException", onUncaughtException) + // Subscribe to global events and forward them via RPC GlobalBus.on("event", (event) => { Rpc.emit("global.event", event) @@ -33,6 +46,8 @@ const runShutdown = createWorkerShutdown({ dispose: () => InstanceRuntime.disposeAllInstances(), stopServer: async () => { if (server) await server.stop(true) + process.off("unhandledRejection", onUnhandledRejection) + process.off("uncaughtException", onUncaughtException) }, }) // kilocode_change end diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index fc7661977ec..a93495c4174 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,16 +1,16 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance-context" -import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" import { legacyReviewCommand, reviewCommand } from "@/kilocode/review/command" // kilocode_change import { apply as applyOverride, type Override } from "@/kilocode/command/override" // kilocode_change -import { EventV2 } from "@opencode-ai/core/event" import PROMPT_INITIALIZE from "./template/initialize.txt" +import { LegacyEvent } from "@opencode-ai/schema/legacy-event" import { SessionResume } from "@/kilocode/session-resume" // kilocode_change type State = { @@ -18,15 +18,7 @@ type State = { } export const Event = { - Executed: EventV2.define({ - type: "command.executed", - schema: { - name: Schema.String, - sessionID: SessionID, - arguments: Schema.String, - messageID: MessageID, - }, - }), + Executed: LegacyEvent.CommandExecuted, } export const Info = Schema.Struct({ @@ -66,19 +58,29 @@ export interface Interface { } // kilocode_change start - skills can share names with slash commands -function fromSkill(item: Skill.Info): Info { +function fromSkill(item: Skill.Info, dir?: string): Info { return { name: item.name, description: item.description, source: "skill", trusted: item.trusted === true, get template() { - return item.content + if (!dir) return item.content + return [ + item.content, + "", + `Base directory for this skill: ${dir}`, + "Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.", + ].join("\n") }, hints: [], } } +function directory(item: Skill.Info) { + return item.location === "" ? undefined : path.dirname(item.location) +} + function skillName(name: string) { return name.endsWith(":skill") ? name.slice(0, -6) : undefined } @@ -90,7 +92,7 @@ function mcpName(name: string) { export class Service extends Context.Service()("@opencode/Command") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -157,7 +159,7 @@ export const layer = Layer.effect( for (const item of yield* skill.all()) { if (commands[item.name]) continue - commands[item.name] = fromSkill(item) // kilocode_change + commands[item.name] = fromSkill(item, directory(item)) // kilocode_change } // kilocode_change start - apply deferred overrides to their registered source @@ -167,7 +169,7 @@ export const layer = Layer.effect( const found = yield* skill.get(skillTarget) if (found) { if (commands[skillTarget]?.source !== "skill") { - commands[item.name] = fromSkill(found) + commands[item.name] = fromSkill(found, directory(found)) applyOverride(commands, item.name, item.command, hints) // kilocode_change } else { applyOverride(commands, skillTarget, item.command, hints) // kilocode_change @@ -205,7 +207,7 @@ export const layer = Layer.effect( const exact = s.commands[target] if (exact?.source === "skill") return exact const item = yield* skill.get(target) - if (item) return fromSkill(item) + if (item) return fromSkill(item, directory(item)) return undefined } // kilocode_change end @@ -226,7 +228,7 @@ export const layer = Layer.effect( const names = new Set(result.map((item) => item.name)) for (const item of yield* skill.all()) { if (s.commands[item.name]?.source === "skill" || s.commands[`${item.name}:skill`]?.source === "skill") continue - if (names.has(item.name)) result.push(fromSkill(item)) + if (names.has(item.name)) result.push(fromSkill(item, directory(item))) } return result }) @@ -236,12 +238,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(Skill.defaultLayer), -) - -export const node = LayerNode.make(layer, [Config.node, MCP.node, Skill.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node, MCP.node, Skill.node] }) export * as Command from "." diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7ce0b002e4d..02dc30b52f9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" import { pathToFileURL } from "url" @@ -23,7 +23,7 @@ import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state" import { FSUtil } from "@opencode-ai/core/fs-util" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath, type InstanceContext } from "../project/instance-context" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" @@ -268,7 +268,7 @@ function writableGlobal(info: Info) { return next } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -1118,26 +1118,12 @@ export const layer = Layer.effect( warnings, // kilocode_change }) }), -).pipe(Layer.provide(EffectFlock.defaultLayer)) // kilocode_change - serialize global config updates in every layer - -export const defaultLayer = layer.pipe( - Layer.provide(Git.defaultLayer), // kilocode_change - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Account.defaultLayer), - Layer.provide(Npm.defaultLayer), - Layer.provide(FetchHttpClient.layer), ) -export const node = LayerNode.make(layer, [ - FSUtil.node, - Auth.node, - Account.node, - Env.node, - Npm.node, - httpClient, - Git.node, -]) // kilocode_change +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient, Git.node, EffectFlock.node], // kilocode_change +}) export * as Config from "./config" diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 60593f951fd..0ee85391ccb 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -2,6 +2,8 @@ export * as TuiConfig from "./tui" import path from "path" import { mergeDeep, unique } from "remeda" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" @@ -257,7 +259,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: } }) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const directory = yield* CurrentWorkingDirectory @@ -292,9 +294,9 @@ export const layer = Layer.effect( }).pipe(Effect.withSpan("TuiConfig.layer")), ) -export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const node = LayerNode.make({ service: Service, layer, deps: [Npm.node, FSUtil.node] }) -const { runPromise } = makeRuntime(Service, defaultLayer) +const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node)) export async function waitForDependencies() { await runPromise((svc) => svc.waitForDependencies()) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index c8f6ab2ce8a..98f017955f8 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,8 +1,8 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" +import { HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { Database } from "@opencode-ai/core/database/database" import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" @@ -31,8 +31,9 @@ import { waitEvent } from "./util" import { WorkspaceRef } from "@/effect/instance-ref" import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" -import { InstanceBootstrap } from "@/project/bootstrap" import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime" +import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1" +import { WorkspaceEvent } from "@opencode-ai/schema/workspace-event" export const Info = Schema.Struct({ ...WorkspaceInfoSchema.fields, @@ -40,27 +41,10 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Workspace" }) export type Info = WorkspaceInfo & { timeUsed: number } -export const ConnectionStatus = Schema.Struct({ - workspaceID: WorkspaceV2.ID, - status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), -}) -export type ConnectionStatus = Schema.Schema.Type +export const ConnectionStatus = WorkspaceEvent.ConnectionStatus +export type ConnectionStatus = WorkspaceEvent.ConnectionStatus -export const Event = { - Ready: EventV2.define({ - type: "workspace.ready", - schema: { - name: Schema.String, - }, - }), - Failed: EventV2.define({ - type: "workspace.failed", - schema: { - message: Schema.String, - }, - }), - Status: EventV2.define({ type: "workspace.status", schema: ConnectionStatus.fields }), -} +export const Event = WorkspaceEvent function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { @@ -131,7 +115,7 @@ export class SyncTimeoutError extends Schema.TaggedErrorClass( export class SyncAbortedError extends Schema.TaggedErrorClass()("WorkspaceSyncAbortedError", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} type CreateError = Auth.AuthError @@ -166,7 +150,7 @@ export class Service extends Context.Service()("@opencode/Wo export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service @@ -617,7 +601,7 @@ export const layer = Layer.effect( }), fallback: "", response: "text", - }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) : "" if (sourcePatch) { @@ -633,7 +617,7 @@ 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(AppNodeBuilderV1.build(InstanceStore.node))) } if (input.workspaceID === null) { @@ -901,21 +885,6 @@ export const layer = Layer.effect( }), ) -// kilocode_change start - prevent Kilo runtime cycles from erasing layer requirements -export const defaultLayer: Layer.Layer = layer.pipe( - // kilocode_change end - Layer.provide(Auth.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(RuntimeFlags.defaultLayer), -) - const TIMEOUT = 5000 type HistoryEvent = { @@ -976,16 +945,20 @@ function route(url: string | URL, path: string) { return next } -export const node = LayerNode.make(layer, [ - Auth.node, - Session.node, - SessionPrompt.node, - httpClient, - EventV2Bridge.node, - Vcs.node, - RuntimeFlags.node, - FSUtil.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Auth.node, + Session.node, + SessionPrompt.node, + httpClient, + EventV2Bridge.node, + Vcs.node, + RuntimeFlags.node, + FSUtil.node, + Database.node, + ], +}) export * as Workspace from "./workspace" diff --git a/packages/opencode/src/effect/app-node-builder-v1.ts b/packages/opencode/src/effect/app-node-builder-v1.ts new file mode 100644 index 00000000000..7573475d9a9 --- /dev/null +++ b/packages/opencode/src/effect/app-node-builder-v1.ts @@ -0,0 +1,12 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" + +const bootstrapReplacement = [InstanceStore.bootstrapNode, InstanceBootstrap.node] as const + +export function build(root: LayerNode.Node, replacements: LayerNode.Replacements = []) { + return AppNodeBuilder.build(root, replacements.concat([bootstrapReplacement])) +} + +export * as AppNodeBuilderV1 from "./app-node-builder-v1" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index eb162bd2d0b..911571a41fc 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -40,7 +40,7 @@ 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 { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -58,96 +58,81 @@ import { Notebook } from "@/kilocode/notebook/service" import { AgentManager } from "@/kilocode/agent-manager/service" // kilocode_change end import { EventV2Bridge } from "@/event-v2-bridge" -// kilocode_change start -import { ProjectV2 } from "@opencode-ai/core/project" -import { ProjectCopy } from "@opencode-ai/core/project/copy" -import { ProjectDirectories } from "@opencode-ai/core/project/directories" -import { MoveSession } from "@opencode-ai/core/control-plane/move-session" -import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { EventV2 } from "@opencode-ai/core/event" -import { Git as GitV2 } from "@opencode-ai/core/git" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilderV1 } from "./app-node-builder-v1" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { EventV2 } from "@opencode-ai/core/event" // kilocode_change +import { ProjectV2 } from "@opencode-ai/core/project" // kilocode_change +import { ProjectCopy } from "@opencode-ai/core/project/copy" // kilocode_change +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" // kilocode_change +import { PtyTicket } from "@opencode-ai/core/pty/ticket" // kilocode_change + +// kilocode_change start - retain Kilo runtime services in the upstream node graph +const memory = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const kilo = LayerNode.group([Credential.node, ModelCache.node, AgentManager.node, Notebook.node, memory]) // kilocode_change end -const CoreLayer = Layer.mergeAll( // kilocode_change - Npm.defaultLayer, - FSUtil.defaultLayer, - Database.defaultLayer, - Credential.defaultLayer, // kilocode_change - Auth.defaultLayer, - Account.defaultLayer, - Config.defaultLayer, - Git.defaultLayer, - Storage.defaultLayer, - Snapshot.defaultLayer, - Plugin.defaultLayer, - ModelCache.defaultLayer, // kilocode_change - ModelsDev.defaultLayer, - Provider.defaultLayer, - ProviderAuth.defaultLayer, - Agent.defaultLayer, - Skill.defaultLayer, - Discovery.defaultLayer, -) // kilocode_change - -// kilocode_change start -const SessionLayer = Layer.mergeAll( - AgentManager.defaultLayer, -// kilocode_change end - Question.defaultLayer, - Notebook.defaultLayer, // kilocode_change - Permission.defaultLayer, - Todo.defaultLayer, - Session.defaultLayer, - SessionStatus.defaultLayer, - BackgroundJob.defaultLayer, - RuntimeFlags.defaultLayer, - EventV2Bridge.defaultLayer, - SessionRunState.defaultLayer, - SessionProcessor.defaultLayer, - SessionCompaction.defaultLayer, - SessionRevert.defaultLayer, - SessionSummary.defaultLayer, - SessionPrompt.defaultLayer, - Instruction.defaultLayer, - LLM.defaultLayer, - LSP.defaultLayer, - MCP.defaultLayer, - McpAuth.defaultLayer, - Command.defaultLayer, - Truncate.defaultLayer, -) // kilocode_change - -const FeatureLayer = Layer.mergeAll( // kilocode_change - ToolRegistry.defaultLayer, - Format.defaultLayer, - Project.defaultLayer, - // kilocode_change start - ProjectV2.defaultLayer, - EventV2.defaultLayer, - ProjectCopy.layer.pipe( - Layer.provide(Database.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(GitV2.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - ), - MoveSession.defaultLayer, - PtyTicket.defaultLayer, - // kilocode_change end - Vcs.defaultLayer, - Workspace.defaultLayer, - Worktree.appLayer, - Installation.defaultLayer, - MemoryService.layer, // kilocode_change - ShareNext.defaultLayer, - SessionShare.defaultLayer, -) // kilocode_change - -export const AppLayer = Layer.mergeAll(CoreLayer, SessionLayer, FeatureLayer).pipe( // kilocode_change - Layer.provideMerge(Ripgrep.defaultLayer), - Layer.provideMerge(InstanceLayer.layer), - Layer.provideMerge(Observability.layer), -) +export const AppLayer = AppNodeBuilderV1.build( + LayerNode.group([ + kilo, // kilocode_change + Npm.node, + FSUtil.node, + Database.node, + Auth.node, + Account.node, + Config.node, + Git.node, + Storage.node, + Snapshot.node, + Plugin.node, + ModelsDev.node, + Provider.node, + ProviderAuth.node, + Agent.node, + Skill.node, + Discovery.node, + Question.node, + Permission.node, + Todo.node, + Session.node, + SessionProjector.node, + SessionStatus.node, + BackgroundJob.node, + RuntimeFlags.node, + EventV2Bridge.node, + SessionRunState.node, + SessionProcessor.node, + SessionCompaction.node, + SessionRevert.node, + SessionSummary.node, + SessionPrompt.node, + Instruction.node, + LLM.node, + LSP.node, + MCP.node, + McpAuth.node, + Command.node, + Truncate.node, + ToolRegistry.node, + Format.node, + InstanceStore.node, + Project.node, + Vcs.node, + Workspace.node, + Worktree.node, + Installation.node, + ShareNext.node, + SessionShare.node, + // kilocode_change start - the app runtime must provide these; the v2 handlers resolve them + // when their layer is built, outside the server's own layer list + EventV2.node, + ProjectV2.node, + ProjectCopy.node, + MoveSession.node, + PtyTicket.node, + // kilocode_change end + ]), +).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index c1987a48ce9..57fe43b46ee 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -1,4 +1,6 @@ import { Layer, ManagedRuntime } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Plugin } from "@/plugin" import { LSP } from "@/lsp/lsp" @@ -10,14 +12,8 @@ import { Config } from "@/config/config" import * as Observability from "@opencode-ai/core/observability" import { memoMap } from "@opencode-ai/core/effect/memo-map" -export const BootstrapLayer = Layer.mergeAll( - Config.defaultLayer, - Plugin.defaultLayer, - ShareNext.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Vcs.defaultLayer, - Snapshot.defaultLayer, +export const BootstrapLayer = AppNodeBuilder.build( + LayerNode.group([Config.node, Plugin.node, ShareNext.node, Format.node, LSP.node, Vcs.node, Snapshot.node]), ).pipe(Layer.provide(Observability.layer)) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) diff --git a/packages/opencode/src/effect/config-service.ts b/packages/opencode/src/effect/config-service.ts index 41680490a83..dcdf734436d 100644 --- a/packages/opencode/src/effect/config-service.ts +++ b/packages/opencode/src/effect/config-service.ts @@ -14,9 +14,9 @@ export type Shape = { */ export type ServiceClass = Context.ServiceClass & { /** Provide already-parsed config, useful in tests. */ - readonly layer: (input: Service) => Layer.Layer + readonly configLayer: (input: Service) => Layer.Layer /** Parse config once from the active Effect ConfigProvider and provide the service. */ - readonly defaultLayer: Layer.Layer + readonly layer: Layer.Layer } /** @@ -35,19 +35,19 @@ export type ServiceClass = Context.ServiceClas * }, * ) {} * - * const live = ServerAuthConfig.defaultLayer - * const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) + * const live = ServerAuthConfig.layer + * const test = ServerAuthConfig.configLayer({ password: Option.some("secret"), username: "kit" }) * ``` */ export const Service = () => (id: Id, fields: Fields) => { class ConfigTag extends Context.Service>()(id) { - static layer(input: Shape) { + static configLayer(input: Shape) { return Layer.succeed(this, this.of(input)) } - static get defaultLayer() { + static get layer() { const tag = this return Layer.effect( tag, diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index f1bf3edb26c..33d3be008cb 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -62,7 +62,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime export type Info = Context.Service.Shape -const emptyConfigLayer = Service.defaultLayer.pipe( +const emptyConfigLayer = Service.layer.pipe( Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), Layer.orDie, ) @@ -76,9 +76,7 @@ export const layer = (overrides: Partial = {}) => }), ).pipe(Layer.provide(emptyConfigLayer)) -export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie) - -export const node = LayerNode.make(defaultLayer, []) +export const node = LayerNode.make({ service: Service, layer: Service.layer.pipe(Layer.orDie), deps: [] }) export * as RuntimeFlags from "./runtime-flags" import { LayerNode } from "@opencode-ai/core/effect/layer-node" diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts index 5f85dc6f2e7..5879f27fe37 100644 --- a/packages/opencode/src/env/index.ts +++ b/packages/opencode/src/env/index.ts @@ -16,7 +16,7 @@ export class Service extends Context.Service()("@opencode/En export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const state = yield* InstanceState.make(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env }))) @@ -36,8 +36,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer - -export const node = LayerNode.make(layer, []) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) export * as Env from "." diff --git a/packages/opencode/src/event-manifest.ts b/packages/opencode/src/event-manifest.ts new file mode 100644 index 00000000000..b84b3f29798 --- /dev/null +++ b/packages/opencode/src/event-manifest.ts @@ -0,0 +1,3 @@ +export * as EventManifest from "./event-manifest" + +export { Definitions, Durable, Latest } from "@opencode-ai/schema/event-manifest" diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 200a18f3032..e0953c2914c 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -3,14 +3,12 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { GlobalBus } from "@/bus/global" +import { EventManifest } from "@/event-manifest" // kilocode_change import * as EventWire from "@/kilocode/event-wire" // kilocode_change import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" -import "@opencode-ai/core/account" -import "@opencode-ai/core/catalog" -import "@opencode-ai/core/session/event" import { Context, Effect, Layer } from "effect" export class Service extends Context.Service()("@opencode/EventV2Bridge") {} @@ -41,7 +39,7 @@ export const layer = Layer.effect( const ctx = yield* InstanceRef const workspaceID = (yield* WorkspaceRef) ?? event.location?.workspaceID // kilocode_change start - legacy bus and SSE consumers require the schema's encoded representation - const definition = EventV2.registry.get(event.type) + const definition = EventManifest.Latest.get(event.type) // kilocode_change const data = definition ? EventWire.encode(definition.data, event.data) : event.data // kilocode_change end GlobalBus.emit("event", { @@ -50,10 +48,7 @@ export const layer = Layer.effect( workspace: workspaceID, payload: { id: event.id, type: event.type, properties: data }, // kilocode_change - encoded }) - const sync = definition?.sync - if (sync === undefined || event.seq === undefined || event.version === undefined) return - const aggregateID = (event.data as Record)[sync.aggregate] - if (typeof aggregateID !== "string") return + if (event.durable === undefined) return GlobalBus.emit("event", { directory: event.location?.directory ?? ctx?.directory ?? "global", // kilocode_change - instance-less events are tagged "global" on the wire project: ctx?.project.id, @@ -62,9 +57,9 @@ export const layer = Layer.effect( type: "sync", syncEvent: { id: event.id, - type: EventV2.versionedType(event.type, event.version), - seq: event.seq, - aggregateID, + type: EventV2.versionedType(event.type, event.durable.version), + seq: event.durable.seq, + aggregateID: event.durable.aggregateID, data, // kilocode_change - encoded }, }, @@ -77,8 +72,9 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) +// kilocode_change - preserve legacy layer composition while EventV2 uses nodes +export const defaultLayer = layer.pipe(Layer.provide(LayerNode.compile(EventV2.node))) -export const node = LayerNode.make(layer, [EventV2.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2.node] }) export * as EventV2Bridge from "./event-v2-bridge" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index e323fcc2439..4288f83efce 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -28,7 +28,7 @@ export class Service extends Context.Service()("@opencode/Fo export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -194,12 +194,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - -export const node = LayerNode.make(layer, [Config.node, AppProcess.node, RuntimeFlags.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, AppProcess.node, RuntimeFlags.node], +}) export * as Format from "." diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 7a37b1cb823..968e394a959 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -100,7 +100,7 @@ const kind = (code: string): Kind => { export class Service extends Context.Service()("@opencode/Git") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const appProcess = yield* AppProcess.Service @@ -343,8 +343,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer)) - -export const node = LayerNode.make(layer, [AppProcess.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [AppProcess.node] }) export * as Git from "." diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index 673d008d998..13256ec5dcf 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,6 +1,6 @@ -import { EventV2 } from "@opencode-ai/core/event" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" +import { IdeEvent } from "@opencode-ai/schema/ide-event" const SUPPORTED_IDES = [ { name: "Windsurf" as const, cmd: "windsurf" }, @@ -10,14 +10,7 @@ const SUPPORTED_IDES = [ { name: "VSCodium" as const, cmd: "codium" }, ] -export const Event = { - Installed: EventV2.define({ - type: "ide.installed", - schema: { - ide: Schema.String, - }, - }), -} +export const Event = IdeEvent export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {}) diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index 15c806430ad..e09f75c9957 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -140,7 +140,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Image") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -260,8 +260,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - -export const node = LayerNode.make(layer, [Config.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node] }) export * as Image from "./image" diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 6f7d937fe48..cc0e7bebd72 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,14 +1,14 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Effect, Layer, Schema, Context, Stream } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { errorMessage } from "@/util/error" import { ChildProcess } from "effect/unstable/process" import { AppProcess } from "@opencode-ai/core/process" import path from "path" -import { EventV2 } from "@opencode-ai/core/event" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import semver from "semver" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" @@ -22,25 +22,13 @@ import { Scoop as KiloScoop, } from "@/kilocode/installation" // kilocode_change end +import { InstallationEvent } from "@opencode-ai/schema/installation-event" export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export type ReleaseType = "patch" | "minor" | "major" -export const Event = { - Updated: EventV2.define({ - type: "installation.updated", - schema: { - version: Schema.String, - }, - }), - UpdateAvailable: EventV2.define({ - type: "installation.update-available", - schema: { - version: Schema.String, - }, - }), -} +export const Event = InstallationEvent export function getReleaseType(current: string, latest: string): ReleaseType { const currMajor = semver.major(current) @@ -108,7 +96,7 @@ export class Service extends Context.Service()("@opencode/In export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const http = yield* HttpClient.HttpClient @@ -403,14 +391,12 @@ export const layer: Layer.Layer) => runPromise((s) => s.latest(...args)) export const method = () => runPromise((s) => s.method()) export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) -export const node = LayerNode.make(layer, [httpClient, AppProcess.node]) - export * as Installation from "." diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 00c25bd9f07..ac56cfe0e14 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -44,6 +44,7 @@ import { withTimeout } from "@/util/timeout" import { Snapshot } from "@/snapshot" import { cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change async function provide(input: { directory: string; fn: () => R }): Promise { const { provide } = await import("@/kilocode/instance") @@ -594,7 +595,7 @@ export namespace KiloSessions { export const defaultLayer = layer.pipe( Layer.provide(Bus.layer), - Layer.provide(Config.defaultLayer), + Layer.provide(AppNodeBuilder.build(Config.node)), Layer.provide(Session.defaultLayer), ) @@ -607,7 +608,9 @@ export namespace KiloSessions { reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" } as const), }) - export const node = LayerNode.suspend(() => LayerNode.make(layer, [Bus.node, Config.node, Session.node])) + export const node = LayerNode.suspend(() => + LayerNode.make({ service: Service, layer, deps: [Bus.node, Config.node, Session.node] }), + ) // kilocode_change - DEF-1: default advertisement for every successful // enableRemote() entry (covers `/remote` after auto-enable already connected). diff --git a/packages/opencode/src/kilocode/agent-manager/service.ts b/packages/opencode/src/kilocode/agent-manager/service.ts index 7aa7a9b42c3..ffd0f7d3512 100644 --- a/packages/opencode/src/kilocode/agent-manager/service.ts +++ b/packages/opencode/src/kilocode/agent-manager/service.ts @@ -2,6 +2,7 @@ import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { Identifier } from "@/id/id" import * as Log from "@opencode-ai/core/util/log" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Context, Deferred, Duration, Effect, Layer, Schema } from "effect" import { ErrorCode, Event, type Failure, type Request, RequestID, type Result } from "./protocol" @@ -148,4 +149,5 @@ export function layer(timeout: Duration.Input = "10 seconds") { } export const defaultLayer = layer().pipe(Layer.provide(Bus.layer)) +export const node = LayerNode.make({ service: Service, layer: layer(), deps: [Bus.node] }) export * as AgentManager from "./service" diff --git a/packages/opencode/src/kilocode/auth/remove.ts b/packages/opencode/src/kilocode/auth/remove.ts index ef6970ca338..9d93f4a0188 100644 --- a/packages/opencode/src/kilocode/auth/remove.ts +++ b/packages/opencode/src/kilocode/auth/remove.ts @@ -1,12 +1,12 @@ import { Auth } from "@/auth" -import { IntegrationSchema } from "@opencode-ai/core/integration/schema" +import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" import { Effect } from "effect" export const remove = Effect.fn("KiloAuth.remove")(function* (key: string) { const auth = yield* Auth.Service const credentials = yield* Credential.Service - const integration = IntegrationSchema.ID.make(key.replace(/\/+$/, "")) + const integration = Integration.ID.make(key.replace(/\/+$/, "")) const existing = yield* credentials.list(integration) yield* Effect.forEach(existing, (credential) => credentials.remove(credential.id), { concurrency: 1, diff --git a/packages/opencode/src/kilocode/bootstrap.ts b/packages/opencode/src/kilocode/bootstrap.ts index 0d3602c47ca..c7d23221c8c 100644 --- a/packages/opencode/src/kilocode/bootstrap.ts +++ b/packages/opencode/src/kilocode/bootstrap.ts @@ -20,6 +20,7 @@ import { installMemoryRuntime } from "@/kilocode/memory/runtime" import { KiloToolRegistry } from "@/kilocode/tool/registry" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { KilocodeWatcher } from "@/kilocode/watcher" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change const log = Log.create({ service: "kilocode-bootstrap" }) @@ -99,25 +100,21 @@ export namespace KilocodeBootstrap { Layer.provide([ KiloSessions.defaultLayer, Session.defaultLayer, - SessionSummary.defaultLayer, - Provider.defaultLayer, + AppNodeBuilder.build(SessionSummary.node), + AppNodeBuilder.build(Provider.node), MemoryService.layer, Bus.defaultLayer, KilocodeWatcher.defaultLayer, ]), ) - const memory = LayerNode.make(MemoryService.layer, []) - const watcher = LayerNode.make(KilocodeWatcher.defaultLayer, []) + const memory = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) + const watcher = LayerNode.make({ service: KilocodeWatcher.Service, layer: KilocodeWatcher.defaultLayer, deps: [] }) export const node = LayerNode.suspend(() => - LayerNode.make(layer, [ - KiloSessions.node, - Session.node, - SessionSummary.node, - Provider.node, - memory, - Bus.node, - watcher, - ]), + LayerNode.make({ + service: Service, + layer, + deps: [KiloSessions.node, Session.node, SessionSummary.node, Provider.node, memory, Bus.node, watcher], + }), ) } diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx index 543750842f7..a2ce6c8bd0a 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx @@ -279,10 +279,12 @@ export function init() { name: "permission.allow_everything", get title() { return isAllowEverything(sync.data.config.permission) - ? "Disable auto-approve mode" - : "Enable auto-approve mode" + ? "Disable saved auto-approve" + : "Enable saved auto-approve" }, - desc: "Toggle auto-approve for all permission prompts, saved to global config", + // kilocode_change - the saved rule is server side, so it also stops VS Code, JetBrains and + // headless runs from prompting + desc: "Toggle auto-approve for all permission prompts, saved to global config and shared with every client", category: "System", slashName: "auto-approve", slashAliases: ["autoapprove", "approve-all", "approveall"], diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 6785e654ba2..fce5d9dee02 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -88,6 +88,7 @@ export namespace KilocodeConfigOverlay { ["model"], ["small_model"], ["hide_prompt_training_models"], + ["privacy_mode"], ["default_agent"], ["snapshot"], ["share"], diff --git a/packages/opencode/src/kilocode/database/sqlite-error.ts b/packages/opencode/src/kilocode/database/sqlite-error.ts new file mode 100644 index 00000000000..31e476a8005 --- /dev/null +++ b/packages/opencode/src/kilocode/database/sqlite-error.ts @@ -0,0 +1,7 @@ +import { isSqlError } from "effect/unstable/sql/SqlError" + +export const busyMessage = "Database is busy. Please try again in a moment." + +export function isBusy(error: unknown) { + return isSqlError(error) && error.reason._tag === "LockTimeoutError" +} diff --git a/packages/opencode/src/kilocode/editor-context.ts b/packages/opencode/src/kilocode/editor-context.ts index 1b90c019808..ef7c0775804 100644 --- a/packages/opencode/src/kilocode/editor-context.ts +++ b/packages/opencode/src/kilocode/editor-context.ts @@ -1,11 +1,14 @@ -export interface EditorContext { - directory?: string - worktree?: string - visibleFiles?: string[] - openTabs?: string[] - activeFile?: string - shell?: string -} +import { Schema, Types } from "effect" + +export const EditorContext = Schema.Struct({ + directory: Schema.optional(Schema.String), + worktree: Schema.optional(Schema.String), + visibleFiles: Schema.optional(Schema.Array(Schema.String)), + openTabs: Schema.optional(Schema.Array(Schema.String)), + activeFile: Schema.optional(Schema.String), + shell: Schema.optional(Schema.String), +}) +export type EditorContext = Types.DeepMutable> /** * Build static lines from editor context. diff --git a/packages/opencode/src/kilocode/kilo-commands.tsx b/packages/opencode/src/kilocode/kilo-commands.tsx index 58bb3e8de26..5ad85cf9747 100644 --- a/packages/opencode/src/kilocode/kilo-commands.tsx +++ b/packages/opencode/src/kilocode/kilo-commands.tsx @@ -11,6 +11,8 @@ import { useRoute } from "@tui/context/route" import { useDialog } from "@tui/ui/dialog" import { useToast } from "@tui/ui/toast" import { DialogAlert } from "@tui/ui/dialog-alert" +import { DialogConfirm } from "@tui/ui/dialog-confirm" +import { reconcile } from "solid-js/store" import type { Organization } from "@kilocode/kilo-gateway" import type { ClawStatus } from "./claw/types.js" import { DialogKiloTeamSelect } from "./components/dialog-kilo-team-select.js" @@ -137,6 +139,15 @@ export function registerKiloCommands(useSDK: () => UseSDK) { hidden: !isKiloConnected(), run: async () => { try { + if (sync.data.globalConfig.privacy_mode === true) { + const confirmed = await DialogConfirm.show( + dialog, + "Privacy Mode Enabled", + "Privacy mode is on. Revealing your profile will display your email, name, balance, and team on screen.", + ) + if (confirmed !== true) return + } + // Fetch profile and balance using server endpoint const response = await sdk.client.kilo.profile() @@ -176,6 +187,39 @@ export function registerKiloCommands(useSDK: () => UseSDK) { ] : []), + // /privacy command + { + name: "kilo.privacy", + get title() { + return sync.data.globalConfig.privacy_mode === true ? "Disable privacy mode" : "Enable privacy mode" + }, + desc: "Blur PII (balance, email, etc.) and confirm before showing profile", + category: "Kilo", + slashName: "privacy", + run: async () => { + const next = sync.data.globalConfig.privacy_mode !== true + const response = await sdk.client.config.overlayUpdate({ + scope: "global", + set: { privacy_mode: next }, + }) + if (response.error) { + const status = response.response?.status ?? "?" + toast.show({ message: `Failed to update privacy mode (${status})`, variant: "error" }) + return + } + const [cfg, global] = await Promise.all([ + sdk.client.config.get({}), + sdk.client.global.config.get({}), + ]) + if (cfg.data) sync.set("config", reconcile(cfg.data)) + if (global.data) sync.set("globalConfig", reconcile(global.data)) + toast.show({ + message: next ? "Privacy mode enabled" : "Privacy mode disabled", + variant: "success", + }) + }, + }, + // /teams command { name: "kilo.teams", diff --git a/packages/opencode/src/kilocode/notebook/service.ts b/packages/opencode/src/kilocode/notebook/service.ts index cc05d32c19d..17d08a727f2 100644 --- a/packages/opencode/src/kilocode/notebook/service.ts +++ b/packages/opencode/src/kilocode/notebook/service.ts @@ -3,6 +3,7 @@ import { InstanceState } from "@/effect/instance-state" import { Identifier } from "@/id/id" import { Deferred, Duration, Effect, Layer, Schema, Context } from "effect" import * as Log from "@opencode-ai/core/util/log" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ErrorCode, Event, type Failure, type Request, RequestID, type Result } from "./protocol" const log = Log.create({ service: "notebook-host" }) @@ -168,4 +169,5 @@ export function layer(timeout: Duration.Input = "10 minutes") { } export const defaultLayer = layer().pipe(Layer.provide(Bus.layer)) +export const node = LayerNode.make({ service: Service, layer: layer(), deps: [Bus.node] }) export * as Notebook from "./service" diff --git a/packages/opencode/src/kilocode/pii.ts b/packages/opencode/src/kilocode/pii.ts new file mode 100644 index 00000000000..f3d90021563 --- /dev/null +++ b/packages/opencode/src/kilocode/pii.ts @@ -0,0 +1 @@ +export const REDACTED_BALANCE = "•••" diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index a0c726f94ca..7fd01715ffc 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -24,11 +24,12 @@ import { lazy } from "@/util/lazy" import path from "path" import z from "zod" import { PlanFile } from "@/kilocode/plan-file" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change -const agents = lazy(() => makeRuntime(Agent.Service, Agent.defaultLayer)) -const providers = lazy(() => makeRuntime(Provider.Service, Provider.defaultLayer)) +const agents = lazy(() => makeRuntime(Agent.Service, AppNodeBuilder.build(Agent.node))) +const providers = lazy(() => makeRuntime(Provider.Service, AppNodeBuilder.build(Provider.node))) const todo = lazy(() => makeRuntime(Todo.Service, Todo.defaultLayer)) -const llm = lazy(() => makeRuntime(LLM.Service, LLM.defaultLayer)) +const llm = lazy(() => makeRuntime(LLM.Service, AppNodeBuilder.build(LLM.node))) const pending = new Map() export const PlanFollowupRuntime = { diff --git a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx index cf605c78bf7..994a1b47a11 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx @@ -5,6 +5,7 @@ import * as Log from "@opencode-ai/core/util/log" import type { KiloPassState } from "@kilocode/kilo-gateway" import type { Message } from "@kilocode/sdk/v2" import { onBalanceRefresh } from "../balance-refresh" +import { REDACTED_BALANCE } from "../pii" const id = "internal:kilo-sidebar-footer" const TEAM_POLL_MS = 5 * 60_000 @@ -37,8 +38,9 @@ export function scope(org: string | null | undefined, list?: readonly { id: stri } } -export function creditLabel(value: ReturnType) { +export function creditLabel(value: ReturnType, masked = false) { if (value.kind === "Personal") return "Personal credits" + if (masked) return "Team credits" return value.name ? `${value.name} team` : "Team credits" } @@ -98,6 +100,9 @@ function View(props: { api: TuiPluginApi }) { name: list.at(-1) ?? "", } }) + const privacyMode = createMemo(() => props.api.state.globalConfig.privacy_mode === true) + const balanceText = createMemo(() => (privacyMode() ? REDACTED_BALANCE : null)) + const mutedColor = createMemo(() => (privacyMode() ? theme().textMuted : tone())) const refresh = () => { const id = ++seq // Cancel any prior request and time this one out — the client path has no fetch timeout, @@ -167,19 +172,20 @@ function View(props: { api: TuiPluginApi }) { {(() => { const balance = data().balance if (balance === undefined) return null + const masked = balanceText() return ( - • + • - {creditLabel(data().scope)} + {creditLabel(data().scope, privacyMode())} - {format(balance)} + {masked ?? format(balance)} ) })()} - + {(pass) => ( diff --git a/packages/opencode/src/kilocode/plugins/sync-v2.tsx b/packages/opencode/src/kilocode/plugins/sync-v2.tsx index 179437875f9..1985d6a7f67 100644 --- a/packages/opencode/src/kilocode/plugins/sync-v2.tsx +++ b/packages/opencode/src/kilocode/plugins/sync-v2.tsx @@ -160,18 +160,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break } case "session.next.prompt.admitted": - break - case "session.next.prompt.promoted": - update(event.properties.sessionID, (draft) => { - prepend(draft, { - id: event.properties.messageID, - type: "user", - text: event.properties.prompt.text, - files: event.properties.prompt.files, - agents: event.properties.prompt.agents, - time: { created: event.properties.timeCreated }, - }) - }) + // upstream removed session.next.prompt.promoted (#33443); promotion now emits session.next.prompted, + // which the case above already projects into a user message break case "session.next.context.updated": update(event.properties.sessionID, (draft) => { @@ -327,7 +317,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( ) if (match?.state.status !== "running") return match.state.structured = event.properties.structured - match.state.content = [...event.properties.content] + match.state.content = event.properties.content }) break case "session.next.tool.success": @@ -341,7 +331,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( status: "completed", input: match.state.input, structured: event.properties.structured, - content: [...event.properties.content], + content: event.properties.content, result: event.properties.result, } match.provider = { diff --git a/packages/opencode/src/kilocode/reference.ts b/packages/opencode/src/kilocode/reference.ts index 5bb088e7ab2..47549878038 100644 --- a/packages/opencode/src/kilocode/reference.ts +++ b/packages/opencode/src/kilocode/reference.ts @@ -163,7 +163,7 @@ export const sync = Effect.fn("KiloReference.sync")(function* (input: { return [ [ item.name, - new Reference.LocalSource({ + Reference.LocalSource.make({ type: "local", path: AbsolutePath.make(item.path), description: item.description, @@ -175,7 +175,7 @@ export const sync = Effect.fn("KiloReference.sync")(function* (input: { return [ [ item.name, - new Reference.GitSource({ + Reference.GitSource.make({ type: "git", repository: item.repository, branch: item.branch, diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts index 5c1afeee401..57a9f0d2742 100644 --- a/packages/opencode/src/kilocode/server/httpapi/server.ts +++ b/packages/opencode/src/kilocode/server/httpapi/server.ts @@ -6,8 +6,8 @@ import { corsVaryFix } from "@/server/routes/instance/httpapi/middleware/cors-va import { errorLayer } from "@/server/routes/instance/httpapi/middleware/error" import { fenceLayer } from "@/server/routes/instance/httpapi/middleware/fence" import * as AnacondaDesktop from "@/kilocode/anaconda-desktop/service" -import { BackgroundJob } from "@/background/job" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1" // kilocode_change - defaultLayer aliases are gone import { KiloViewers } from "@/kilocode/presence/service" // kilocode_change import { agentBuilderHandlers } from "./handlers/agent-builder" @@ -46,7 +46,7 @@ export const provide = Layer.provide([ memoryHandlers, networkHandlers, remoteHandlers, - sandboxHandlers.pipe(Layer.provide(BackgroundJob.defaultLayer)), + sandboxHandlers, sessionImportHandlers, suggestionHandlers, telemetryHandlers, @@ -67,7 +67,7 @@ export function provideListener(opts?: CorsOptions) { fenceLayer, cors, KiloViewers.defaultLayer, // kilocode_change - EffectFlock.defaultLayer, + AppNodeBuilderV1.build(EffectFlock.node), FetchHttpClient.layer, HttpServer.layerServices, Layer.succeed(CorsConfig)(opts), diff --git a/packages/opencode/src/kilocode/server/reference-reconciler.ts b/packages/opencode/src/kilocode/server/reference-reconciler.ts index 2d2c7a0e34d..11ad901b697 100644 --- a/packages/opencode/src/kilocode/server/reference-reconciler.ts +++ b/packages/opencode/src/kilocode/server/reference-reconciler.ts @@ -3,9 +3,9 @@ import { InstanceRef } from "@/effect/instance-ref" import { isInterrupted } from "@/kilocode/effect/cause" import * as KiloReference from "@/kilocode/reference" import { InstanceStore } from "@/project/instance-store" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { PluginV2 } from "@opencode-ai/core/plugin" // kilocode_change import { ReferenceReconciler } from "@opencode-ai/server/kilocode/reference-reconciler" import { Effect, Layer } from "effect" @@ -16,7 +16,7 @@ const reconcile = Effect.gen(function* () { const location = yield* Location.Service const ctx = yield* store.load({ directory: location.directory }) const cfg = yield* config.get().pipe(Effect.provideService(InstanceRef, ctx)) - yield* PluginBoot.Service.use((boot) => boot.wait()) + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) // kilocode_change yield* KiloReference.sync({ references: cfg.references ?? cfg.reference ?? {}, directory: ctx.directory, @@ -31,11 +31,11 @@ const reconcile = Effect.gen(function* () { export const layer = Layer.effect(ReferenceReconciler, reconcile) export const locations = Layer.effect( - LocationServiceMap, + LocationServiceMap.Service, Effect.gen(function* () { - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const initialize = yield* reconcile - return LocationServiceMap.of({ + return LocationServiceMap.Service.of({ ...locations, get: (ref) => Layer.effectDiscard(initialize).pipe(Layer.provideMerge(locations.get(ref))), contextEffect: (ref) => @@ -46,4 +46,4 @@ export const locations = Layer.effect( }), }) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) diff --git a/packages/opencode/src/kilocode/session-export/org-sources.ts b/packages/opencode/src/kilocode/session-export/org-sources.ts index 9f19af95552..88bfd9085b4 100644 --- a/packages/opencode/src/kilocode/session-export/org-sources.ts +++ b/packages/opencode/src/kilocode/session-export/org-sources.ts @@ -2,11 +2,12 @@ import { Auth } from "@/auth" import { Config } from "@/config/config" import { makeRuntime } from "@/effect/run-service" import { resolveKiloIndexingAuth } from "@/kilocode/indexing-auth" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change export type OrgState = { type: "personal" } | { type: "org"; id: string } | { type: "unknown" } export type OrgSource = () => Promise -const config = makeRuntime(Config.Service, Config.defaultLayer) +const config = makeRuntime(Config.Service, AppNodeBuilder.build(Config.node)) const auth = makeRuntime(Auth.Service, Auth.defaultLayer) export async function getAuthOrgId(): Promise { diff --git a/packages/opencode/src/kilocode/session-import/service.ts b/packages/opencode/src/kilocode/session-import/service.ts index b9a414e424a..a38d92b143a 100644 --- a/packages/opencode/src/kilocode/session-import/service.ts +++ b/packages/opencode/src/kilocode/session-import/service.ts @@ -1,6 +1,7 @@ import { Database } from "@opencode-ai/core/database/database" import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" import { SessionID, MessageID, PartID } from "../../session/schema" +import { SessionMessage } from "@opencode-ai/core/session/message" // kilocode_change - shared Revert.State brand import { ProjectV2 } from "@opencode-ai/core/project" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionImportType } from "./types" @@ -44,7 +45,7 @@ export namespace SessionImportService { const revert = input.revert ? { ...input.revert, - messageID: MessageID.make(input.revert.messageID), + messageID: SessionMessage.ID.make(input.revert.messageID), // kilocode_change - shared Revert.State brand partID: input.revert.partID ? PartID.make(input.revert.partID) : undefined, } : undefined diff --git a/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts index 146fa1613c1..2f2b2f64a74 100644 --- a/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts +++ b/packages/opencode/src/kilocode/session-portability/cumulative-diff.ts @@ -3,6 +3,7 @@ import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import type { SessionID } from "@/session/schema" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change export type PortableDiff = Snapshot.FileDiff & { after?: string @@ -51,7 +52,7 @@ export function cumulativeSessionDiff(storage: Storage.Interface, id: SessionID // Self-contained Storage runtime so shared callers (Session.fork) can carry fork diffs without taking a // legacy Storage dependency in their layer. Mirrors the Database runtime pattern in session/session.ts. -const runtime = makeRuntime(Storage.Service, Storage.defaultLayer) +const runtime = makeRuntime(Storage.Service, AppNodeBuilder.build(Storage.node)) /** * Carry a source session's cumulative diff base onto a freshly forked session, so imported/cumulative diff --git a/packages/opencode/src/kilocode/storage/json-migration.ts b/packages/opencode/src/kilocode/storage/json-migration.ts index 62a56f67119..431c13caf50 100644 --- a/packages/opencode/src/kilocode/storage/json-migration.ts +++ b/packages/opencode/src/kilocode/storage/json-migration.ts @@ -14,6 +14,7 @@ import { Glob } from "@opencode-ai/core/util/glob" import { EOL } from "os" import { Effect } from "effect" import { errorMessage } from "@/util/error" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change const log = Log.create({ service: "json-migration" }) @@ -84,7 +85,7 @@ export async function bootstrap() { let last = -1 if (tty) process.stderr.write("\x1b[?25l") try { - await Effect.runPromise(Database.Service.use(() => Effect.void).pipe(Effect.provide(Database.defaultLayer))) + await Effect.runPromise(Database.Service.use(() => Effect.void).pipe(Effect.provide(AppNodeBuilder.build(Database.node)))) const sqlite = new BunDatabase(marker) try { const stats = await run(drizzle({ client: sqlite }), { diff --git a/packages/opencode/src/kilocode/tui/config.ts b/packages/opencode/src/kilocode/tui/config.ts index b7a44ccd9ec..e7d6d333f22 100644 --- a/packages/opencode/src/kilocode/tui/config.ts +++ b/packages/opencode/src/kilocode/tui/config.ts @@ -12,6 +12,7 @@ import { Filesystem } from "@/util/filesystem" import { isRecord } from "@/util/record" import { GlobalBus } from "@/bus/global" import { Event } from "@/server/event" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" export namespace KilocodeTuiConfig { export const Scope = z.enum(["project", "global"]) @@ -28,7 +29,9 @@ export namespace KilocodeTuiConfig { const cfg = await Effect.runPromise( TuiConfig.Service.use((svc) => svc.info()).pipe( Effect.provide( - TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, input.directory))), + AppNodeBuilder.build(TuiConfig.node).pipe( + Layer.provide(Layer.succeed(CurrentWorkingDirectory, input.directory)), + ), ), ), ) diff --git a/packages/opencode/src/kilocode/watcher.ts b/packages/opencode/src/kilocode/watcher.ts index 1e7f70f39fd..fd88764e24e 100644 --- a/packages/opencode/src/kilocode/watcher.ts +++ b/packages/opencode/src/kilocode/watcher.ts @@ -2,7 +2,7 @@ import { InstanceState } from "@/effect/instance-state" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { AbsolutePath } from "@opencode-ai/core/schema" import { Cause, Context, Effect, Layer, Scope } from "effect" @@ -15,14 +15,23 @@ export namespace KilocodeWatcher { export class Service extends Context.Service()("@kilocode/Watcher") {} + // Embedded editor clients (VS Code, JetBrains) have their own file watching + // and git integration and do not consume the CLI's vcs.branch.updated event, + // so they must not eagerly warm the location stack — that starts a native + // @parcel/watcher subscription per instance that lives for the whole session. + // On macOS FSEvents watches the entire subtree recursively (the ignore list + // is only a userspace filter), so an always-on, consumer-less watcher on a + // churny workspace burns CPU and leaks native memory while idle. The + // standalone CLI/TUI stays eager because its sidebar branch label is the only + // consumer and no request-driven route would otherwise build the stack. export function eager(client = Flag.KILO_CLIENT) { - return client !== "vscode" + return client !== "vscode" && client !== "jetbrains" } export const layer = Layer.effect( Service, Effect.gen(function* () { - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const scope = yield* Scope.Scope const state = yield* InstanceState.make( @@ -60,7 +69,7 @@ export namespace KilocodeWatcher { Effect.gen(function* () { if (!eager() || (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => false)))) return Layer.succeed(Service, Service.of({ init: () => Effect.void })) - return layer.pipe(Layer.provide(LocationServiceMap.layer)) + return layer.pipe(Layer.provide(locationServiceMapLayer)) }), ) } diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 0949ec1be08..08d8a53d9be 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -28,7 +28,7 @@ export type Diagnostic = VSCodeDiagnostic export class InitializeError extends Schema.TaggedErrorClass()("LSPInitializeError", { serverID: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} type DocumentDiagnosticReport = { diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index f4e6a4bb2ad..7096cb83f4a 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -1,7 +1,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import * as LSPClient from "./client" import path from "path" import { pathToFileURL, fileURLToPath } from "url" @@ -15,10 +14,9 @@ import { containsPath } from "@/project/instance-context" import { TsClient } from "../kilocode/ts-client" // kilocode_change import { NonNegativeInt } from "@opencode-ai/core/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { LspEvent } from "@opencode-ai/schema/lsp-event" -export const Event = { - Updated: EventV2.define({ type: "lsp.updated", schema: {} }), -} +export const Event = LspEvent const Position = Schema.Struct({ line: NonNegativeInt, @@ -138,7 +136,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/LSP") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -514,14 +512,12 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), -) - export * as Diagnostic from "./diagnostic" -export const node = LayerNode.make(layer, [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node], +}) export * as LSP from "./lsp" diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index be03760c52b..808aa302962 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -50,14 +50,13 @@ export interface Interface { readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect readonly getOAuthState: (mcpName: string) => Effect.Effect readonly clearOAuthState: (mcpName: string) => Effect.Effect - readonly isTokenExpired: (mcpName: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/McpAuth") {} export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -142,13 +141,6 @@ export const layer = Layer.effect( return entry?.oauthState }) - const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) { - const entry = yield* get(mcpName) - if (!entry?.tokens) return null - if (!entry.tokens.expiresAt) return false - return entry.tokens.expiresAt < Date.now() / 1000 - }) - return Service.of({ all, get, @@ -162,13 +154,10 @@ export const layer = Layer.effect( updateOAuthState, getOAuthState, clearOAuthState, - isTokenExpired, }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer)) - -export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EffectFlock.node] }) export * as McpAuth from "./auth" diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 6a41b617e53..5cc5559b5b8 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -73,7 +73,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) }], @@ -87,6 +88,7 @@ export function collect( // kilocode_change - distin client: Client, list: (client: Client) => Promise, label: string, + key?: (item: T) => string, ) { return Effect.tryPromise({ try: () => list(client), @@ -100,8 +102,13 @@ export function collect( // kilocode_change - distin ), Effect.map((items) => { const sanitizedClient = sanitize(clientName) + // Escape both the separator and escape marker so `server:uri` keys remain unambiguous. + const resourceClient = clientName.replaceAll("%", "%25").replaceAll(":", "%3A") return Object.fromEntries( - items.map((item) => [sanitizedClient + ":" + sanitize(item.name), { ...item, client: clientName }]), + items.map((item) => [ + key ? resourceClient + ":" + key(item) : sanitizedClient + ":" + sanitize(item.name), + { ...item, client: clientName }, + ]), ) }), Effect.orElseSucceed(() => undefined), @@ -110,6 +117,8 @@ export function collect( // kilocode_change - distin export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_") +export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name) + export function prompts(client: Client, timeout?: number) { if (!client.getServerCapabilities()?.prompts) return Promise.resolve([]) return paginate( @@ -126,6 +135,14 @@ export function resources(client: Client, timeout?: number) { ) } +export function resourceTemplates(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resourceTemplates, + ) +} + function listTools(client: Client, timeout: number) { return Effect.tryPromise({ try: () => diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index bcbb1033a19..50e2d92f6c4 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -31,20 +31,20 @@ import { NamedError } from "@opencode-ai/core/util/error" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { withTimeout } from "@/util/timeout" import { FSUtil } from "@opencode-ai/core/fs-util" -import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" import { McpAuth } from "./auth" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import { TuiEvent } from "@/server/tui-event" import open from "open" -import { Cause, Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect" +import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as SandboxNetwork from "@/kilocode/sandbox/network" // kilocode_change import { McpCatalog } from "./catalog" +import { McpEvent } from "@opencode-ai/schema/mcp-event" const DEFAULT_TIMEOUT = 30_000 const CLIENT_OPTIONS = { @@ -83,20 +83,9 @@ export const Resource = Schema.Struct({ }).annotate({ identifier: "McpResource" }) export type Resource = Schema.Schema.Type -export const ToolsChanged = EventV2.define({ - type: "mcp.tools.changed", - schema: { - server: Schema.String, - }, -}) +export const ToolsChanged = McpEvent.ToolsChanged -export const BrowserOpenFailed = EventV2.define({ - type: "mcp.browser.open.failed", - schema: { - mcpName: Schema.String, - url: Schema.String, - }, -}) +export const BrowserOpenFailed = McpEvent.BrowserOpenFailed export const Failed = NamedError.create("MCPFailed", { name: Schema.String, @@ -144,11 +133,12 @@ export type Status = Schema.Schema.Type // Store transports for OAuth servers to allow finishing auth type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport -const pendingOAuthTransports = new Map() +const pendingOAuthTransports = new Map() // Prompt cache types type PromptInfo = Awaited>["prompts"][number] type ResourceInfo = Awaited>["resources"][number] +type ResourceTemplateInfo = Awaited>["resourceTemplates"][number] type McpEntry = NonNullable[string] function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info { @@ -163,6 +153,7 @@ interface CreateResult { mcpClient?: MCPClient status: Status defs?: MCPToolDef[] + instructions?: string } interface AuthResult { @@ -178,14 +169,25 @@ interface State { status: Record clients: Record defs: Record + instructions: Record +} + +export interface ServerInstructions { + name: string + instructions: string + tools: string[] } export interface Interface { readonly status: () => Effect.Effect> readonly clients: () => Effect.Effect> + readonly instructions: () => Effect.Effect readonly tools: () => Effect.Effect> readonly prompts: () => Effect.Effect> - readonly resources: () => Effect.Effect> + readonly resources: (clientName?: string) => Effect.Effect> + readonly resourceTemplates: ( + clientName?: string, + ) => Effect.Effect> readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record | Status }> readonly connect: (name: string) => Effect.Effect readonly disconnect: (name: string) => Effect.Effect @@ -201,7 +203,10 @@ export interface Interface { readonly startAuth: ( mcpName: string, ) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError> - readonly authenticate: (mcpName: string) => Effect.Effect + readonly authenticate: ( + mcpName: string, + onAuthorization?: (authorizationUrl: string) => void, + ) => Effect.Effect readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect readonly removeAuth: (mcpName: string) => Effect.Effect readonly supportsOAuth: (mcpName: string) => Effect.Effect @@ -213,7 +218,7 @@ export class Service extends Context.Service()("@opencode/MC export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -320,7 +325,7 @@ export const layer = Layer.effect( }) .pipe(Effect.ignore, Effect.as(undefined)) } else { - pendingOAuthTransports.set(key, transport) + pendingOAuthTransports.set(key, { transport }) lastStatus = { status: "needs_auth" as const } return events .publish(TuiEvent.ToastShow, { @@ -410,7 +415,12 @@ export const layer = Layer.effect( if (!listed) { return yield* Effect.fail(new Error("Failed to get tools")) } - return { mcpClient, status, defs: listed } satisfies CreateResult + return { + mcpClient, + status, + defs: listed, + instructions: mcpClient.getInstructions()?.trim(), + } satisfies CreateResult }).pipe( Effect.catchCause((cause) => Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))), @@ -457,6 +467,7 @@ export const layer = Layer.effect( if (s.clients[name] !== client) return delete s.clients[name] delete s.defs[name] + delete s.instructions[name] s.status[name] = { status: "failed", error: "Connection closed" } bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( @@ -511,6 +522,7 @@ export const layer = Layer.effect( status: {}, clients: {}, defs: {}, + instructions: {}, } yield* Effect.forEach( @@ -532,6 +544,7 @@ export const layer = Layer.effect( if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! + if (result.instructions) s.instructions[key] = result.instructions watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -543,6 +556,7 @@ export const layer = Layer.effect( const clients = Object.values(s.clients) s.clients = {} s.defs = {} + s.instructions = {} yield* Effect.forEach( clients, (client) => @@ -572,6 +586,7 @@ export const layer = Layer.effect( const client = s.clients[name] delete s.clients[name] delete s.defs[name] + delete s.instructions[name] if (!client) return Effect.void return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) } @@ -581,6 +596,7 @@ export const layer = Layer.effect( name: string, client: MCPClient, listed: MCPToolDef[], + instructions: string | undefined, timeout?: number, ) { const bridge = yield* EffectBridge.make() @@ -588,6 +604,8 @@ export const layer = Layer.effect( s.status[name] = { status: "connected" } s.clients[name] = client s.defs[name] = listed + if (instructions) s.instructions[name] = instructions + else delete s.instructions[name] watch(s, name, client, bridge, timeout) if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore) return s.status[name] @@ -617,6 +635,18 @@ export const layer = Layer.effect( return s.clients }) + const instructions = Effect.fn("MCP.instructions")(function* () { + const s = yield* InstanceState.get(state) + return Object.entries(s.instructions) + .filter(([name]) => s.status[name]?.status === "connected") + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, item]) => ({ + name, + instructions: item, + tools: (s.defs[name] ?? []).map((tool) => McpCatalog.toolName(name, tool.name)), + })) + }) + const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) { const s = yield* InstanceState.get(state) const result = yield* create(name, mcp) @@ -628,7 +658,7 @@ export const layer = Layer.effect( return result.status } - return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) + return yield* storeClient(s, name, result.mcpClient, result.defs!, result.instructions, mcp.timeout) }) const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCPV1.Info) { @@ -675,7 +705,7 @@ export const layer = Layer.effect( } const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const mcpTool of listed) { - const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name) + const key = McpCatalog.toolName(clientName, mcpTool.name) // kilocode_change start - remote MCP calls must use the sandbox network authority const tool = McpCatalog.convertTool(mcpTool, client, timeout) result[key] = entry?.type === "remote" ? SandboxNetwork.remote(tool) : tool @@ -689,11 +719,15 @@ export const layer = Layer.effect( s: State, listFn: (c: Client, timeout?: number) => Promise, label: string, + key?: (item: T) => string, + targetClientName?: string, ) { return Effect.gen(function* () { const cfg = yield* cfgSvc.get() return yield* Effect.forEach( - Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"), + Object.entries(s.clients).filter( + ([name]) => s.status[name]?.status === "connected" && (!targetClientName || name === targetClientName), + ), ([clientName, client]) => McpCatalog.collect( // kilocode_change - distinguish collection from direct network fetch @@ -701,6 +735,7 @@ export const layer = Layer.effect( client, (c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)), label, + key, ).pipe(Effect.map((items) => Object.entries(items ?? {}))), { concurrency: "unbounded" }, ).pipe(Effect.map((results) => Object.fromEntries(results.flat()))) @@ -711,8 +746,24 @@ export const layer = Layer.effect( return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.prompts, "prompts") }) - const resources = Effect.fn("MCP.resources")(function* () { - return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.resources, "resources") + const resources = Effect.fn("MCP.resources")(function* (clientName?: string) { + return yield* collectFromConnected( + yield* InstanceState.get(state), + McpCatalog.resources, + "resources", + (resource) => resource.uri, + clientName, + ) + }) + + const resourceTemplates = Effect.fn("MCP.resourceTemplates")(function* (clientName?: string) { + return yield* collectFromConnected( + yield* InstanceState.get(state), + McpCatalog.resourceTemplates, + "resource templates", + (template) => template.uriTemplate, + clientName, + ) }) const withClient = Effect.fnUntraced(function* ( @@ -809,7 +860,7 @@ export const layer = Layer.effect( .join("") yield* auth.updateOAuthState(mcpName, oauthState) let capturedUrl: URL | undefined - const authProvider = new McpOAuthProvider( + const authProvider = new McpOAuthPendingProvider( mcpName, mcpConfig.url, { @@ -835,15 +886,16 @@ export const layer = Layer.effect( return yield* Effect.tryPromise({ try: () => { const client = createClient(directory) - return client - .connect(transport) - .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) + return client.connect(transport).then(async () => { + await authProvider.commit() + return { authorizationUrl: "", oauthState, client } satisfies AuthResult + }) }, catch: (error) => error, }).pipe( Effect.catch((error) => { if (error instanceof UnauthorizedError && capturedUrl) { - pendingOAuthTransports.set(mcpName, transport) + pendingOAuthTransports.set(mcpName, { transport, provider: authProvider }) return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult) } return Effect.die(error) @@ -851,7 +903,10 @@ export const layer = Layer.effect( ) }) - const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) { + const authenticate = Effect.fn("MCP.authenticate")(function* ( + mcpName: string, + onAuthorization?: (authorizationUrl: string) => void, + ) { const result = yield* startAuth(mcpName, { callback: false }) // kilocode_change if (!result.authorizationUrl) { const client = "client" in result ? result.client : undefined @@ -871,7 +926,7 @@ export const layer = Layer.effect( const s = yield* InstanceState.get(state) yield* auth.clearOAuthState(mcpName) - return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout) + return yield* storeClient(s, mcpName, client, listed, client.getInstructions()?.trim(), mcpConfig.timeout) } // kilocode_change start - bind only after redirect exists, and clean up if binding fails const mcpConfig = yield* getMcpConfig(mcpName) @@ -892,11 +947,11 @@ export const layer = Layer.effect( }), ) if (err) { - const transport = pendingOAuthTransports.get(mcpName) + const pending = pendingOAuthTransports.get(mcpName) pendingOAuthTransports.delete(mcpName) yield* auth.clearOAuthState(mcpName) yield* auth.clearCodeVerifier(mcpName) - yield* Effect.tryPromise(() => transport?.close() ?? Promise.resolve()).pipe(Effect.ignore) + yield* Effect.tryPromise(() => pending?.transport.close() ?? Promise.resolve()).pipe(Effect.ignore) return { status: "failed", error: err.message } as Status } // kilocode_change end @@ -908,6 +963,7 @@ export const layer = Layer.effect( }) const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) + onAuthorization?.(result.authorizationUrl) yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( Effect.flatMap((subprocess) => @@ -943,26 +999,28 @@ export const layer = Layer.effect( const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) { yield* requireMcpConfig(mcpName) - const transport = pendingOAuthTransports.get(mcpName) - if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) + const pending = pendingOAuthTransports.get(mcpName) + if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) - const result = yield* Effect.tryPromise({ - try: () => transport.finishAuth(authorizationCode).then(() => true as const), - catch: (error) => { - return error - }, - }).pipe(Effect.option) + const error = yield* Effect.tryPromise({ + try: () => pending.transport.finishAuth(authorizationCode), + catch: (error) => error, + }).pipe( + Effect.match({ + onFailure: (error) => (error instanceof Error ? error.message : String(error)), + onSuccess: () => undefined, + }), + ) - if (Option.isNone(result)) { - return { status: "failed", error: "OAuth completion failed" } satisfies Status - } + if (error) return { status: "failed", error: `OAuth completion failed: ${error}` } satisfies Status + yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve()) yield* auth.clearCodeVerifier(mcpName) pendingOAuthTransports.delete(mcpName) const mcpConfig = yield* requireMcpConfig(mcpName) - return yield* createAndStore(mcpName, mcpConfig) + return yield* createAndStore(mcpName, { ...mcpConfig, enabled: true }) }) const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) { @@ -982,18 +1040,25 @@ export const layer = Layer.effect( }) const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) { - const entry = yield* auth.get(mcpName) + const runtimeConfig = (yield* InstanceState.has(state)) + ? (yield* InstanceState.get(state)).config[mcpName] + : undefined + const mcpConfig = runtimeConfig ?? (yield* cfgSvc.get()).mcp?.[mcpName] + if (!mcpConfig || !isMcpConfigured(mcpConfig) || mcpConfig.type !== "remote") return "not_authenticated" + const entry = yield* auth.getForUrl(mcpName, mcpConfig.url) if (!entry?.tokens) return "not_authenticated" - const expired = yield* auth.isTokenExpired(mcpName) - return expired ? "expired" : "authenticated" + if (entry.tokens.expiresAt && entry.tokens.expiresAt < Date.now() / 1000) return "expired" + return "authenticated" }) return Service.of({ status, clients, + instructions, tools, prompts, resources, + resourceTemplates, add, connect, disconnect, @@ -1012,16 +1077,10 @@ export const layer = Layer.effect( export type AuthStatus = "authenticated" | "expired" | "not_authenticated" -// --- Per-service runtime --- - -export const defaultLayer = layer.pipe( - Layer.provide(McpAuth.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), -) - -export const node = LayerNode.make(layer, [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node], +}) export * as MCP from "." diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 71bf1801218..db7f829777e 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -46,14 +46,14 @@ const HTML_ERROR = (error: string) => ` .container { text-align: center; padding: 2rem; } h1 { color: #f87171; margin-bottom: 1rem; } p { color: #aaa; } - .error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; } + .detail { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; white-space: pre-wrap; }

Authorization Failed

An error occurred during authorization.

-
${escapeHtml(error)}
+
${escapeHtml(error)}
` @@ -167,51 +167,7 @@ export async function ensureRunning(redirectUri?: string): Promise { info: (msg, data) => log.info(msg, data), error: (msg, data) => log.error(msg, data), }) - return // kilocode_change end - - // Parse the redirect URI to get port and path (uses defaults if not provided) - const { port, path } = parseRedirectUri(redirectUri) - - // If server is running on a different port/path, stop it first - if (server && (currentPort !== port || currentPath !== path)) { - await stop() - } - - if (server) return - - const running = await isPortInUse(port) - if (running) { - return - } - - currentPort = port - currentPath = path - - server = createServer(handleRequest) - await new Promise((resolve, reject) => { - // kilocode_change start - EADDRINUSE can still fire when another process - // races us between isPortInUse() and listen() (notably across parallel - // bun test subprocesses). Treat it as "another instance owns the port", - // matching the isPortInUse() branch above instead of crashing. - const onError = (err: Error & { code?: string }) => { - if (err.code === "EADDRINUSE") { - log.info("oauth callback port bound by another instance", { port: currentPort }) - server?.close() - server = undefined - resolve() - return - } - reject(err) - } - server!.on("error", onError) - // kilocode_change end - server!.listen(currentPort, () => { - server!.off("error", onError) // kilocode_change - log.info("oauth callback server started", { port: currentPort, path: currentPath }) - resolve() - }) - }) } export function waitForCallback(oauthState: string, mcpName?: string): Promise { diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 67e20d5bc9e..648ffe85b6d 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -25,11 +25,11 @@ export interface McpOAuthCallbacks { export class McpOAuthProvider implements OAuthClientProvider { constructor( - private mcpName: string, - private serverUrl: string, - private config: McpOAuthConfig, + protected mcpName: string, + protected serverUrl: string, + protected config: McpOAuthConfig, private callbacks: McpOAuthCallbacks, - private auth: McpAuth.Interface, + protected auth: McpAuth.Interface, ) {} get redirectUrl(): string { @@ -53,7 +53,6 @@ export class McpOAuthProvider implements OAuthClientProvider { } async clientInformation(): Promise { - // Check config first (pre-registered client) if (this.config.clientId) { return { client_id: this.config.clientId, @@ -164,10 +163,7 @@ export class McpOAuthProvider implements OAuthClientProvider { async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { const entry = await Effect.runPromise(this.auth.get(this.mcpName)) - if (!entry) { - return - } - + if (!entry) return switch (type) { case "all": await Effect.runPromise(this.auth.remove(this.mcpName)) @@ -184,6 +180,63 @@ export class McpOAuthProvider implements OAuthClientProvider { } } +export class McpOAuthPendingProvider extends McpOAuthProvider { + private pendingClientInfo?: OAuthClientInformationFull + private pendingTokens?: OAuthTokens + + override async clientInformation(): Promise { + if (!this.config.clientId) return this.pendingClientInfo + return { + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + } + } + + override async saveClientInformation(info: OAuthClientInformationFull): Promise { + this.pendingClientInfo = info + } + + override async tokens(): Promise { + return this.pendingTokens + } + + override async saveTokens(tokens: OAuthTokens): Promise { + this.pendingTokens = tokens + } + + override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { + if (type === "all" || type === "client") this.pendingClientInfo = undefined + if (type === "all" || type === "tokens") this.pendingTokens = undefined + } + + async commit(): Promise { + if (!this.pendingTokens) return + await Effect.runPromise( + this.auth.set( + this.mcpName, + { + tokens: { + accessToken: this.pendingTokens.access_token, + refreshToken: this.pendingTokens.refresh_token, + expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined, + scope: this.pendingTokens.scope, + }, + clientInfo: + this.pendingClientInfo && !this.config.clientId + ? { + clientId: this.pendingClientInfo.client_id, + clientSecret: this.pendingClientInfo.client_secret, + clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at, + clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at, + } + : undefined, + }, + this.serverUrl, + ), + ) + } +} + export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } /** diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 8ffd4c53b3f..6c77b0d80ef 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import * as Config from "@/config/config" // kilocode_change import { InstanceState } from "@/effect/instance-state" @@ -10,7 +11,6 @@ import { zod } from "@opencode-ai/core/effect-zod" // kilocode_change import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Database } from "@opencode-ai/core/database/database" // kilocode_change import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import { SessionID } from "@/session/schema" // kilocode_change - used by AllowEverythingInput // kilocode_change start import { ConfigProtection } from "@/kilocode/permission/config-paths" @@ -21,17 +21,7 @@ import { AgentManagerPermission } from "@/kilocode/permission/agent-manager" // import { ExternalDirectoryPermission } from "@/kilocode/permission/external-directory" // kilocode_change end -export const Event = { - Asked: EventV2.define({ type: "permission.asked", schema: PermissionV1.Request.fields }), - Replied: EventV2.define({ - type: "permission.replied", - schema: { - sessionID: PermissionV1.Request.fields.sessionID, - requestID: PermissionV1.ID, - reply: PermissionV1.Reply, - }, - }), -} +export const Event = PermissionV1.Event // kilocode_change start - upstream moved these types into PermissionV1; re-export them here so existing // Kilo callers that import off `Permission.*` keep working without a repo-wide rewrite export const Rule = PermissionV1.Rule @@ -165,7 +155,7 @@ function covered(entry: PendingEntry, approved: Ruleset, local: Ruleset) { export class Service extends Context.Service()("@opencode/Permission") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -509,22 +499,17 @@ export function merge(...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule[] export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set { const edits = ["edit", "write", "apply_patch"] + const reads = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"] return new Set( tools.filter((tool) => { - const permission = edits.includes(tool) ? "edit" : tool + const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) return rule?.pattern === "*" && rule.action === "deny" }), ) } -// kilocode_change start - Kilo permission persistence and headless ancestry dependencies -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), -) -// kilocode_change end +export const defaultLayer: Layer.Layer = Layer.suspend(() => AppNodeBuilder.build(node)) // kilocode_change - build from the LayerNode graph // kilocode_change start — inverse of fromConfig: convert rules back to config format const SCALAR_ONLY_PERMISSIONS = new Set(["todowrite", "todoread", "question", "webfetch", "websearch", "doom_loop"]) @@ -553,6 +538,10 @@ export function toConfig(rules: Ruleset): ConfigPermissionV1.Info { } // kilocode_change end -export const node = LayerNode.make(layer, [EventV2Bridge.node, Config.node, Database.node]) // kilocode_change +export const node = LayerNode.make({ + service: Service, + layer, + deps: [EventV2Bridge.node, Config.node, Database.node], // kilocode_change +}) export * as Permission from "." diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts index 3a9c7d16353..94368bebb54 100644 --- a/packages/opencode/src/plugin/digitalocean.ts +++ b/packages/opencode/src/plugin/digitalocean.ts @@ -58,6 +58,7 @@ function buildAuthorizeUrl(state: string): string { return `${DO_AUTHORIZE_URL}?${params.toString()}` } +// kilocode_change start - retain Kilo-branded OAuth callback until the shared page supports Kilo branding const HTML_CALLBACK = ` @@ -116,7 +117,7 @@ const HTML_CALLBACK = ` ` - +// kilocode_change end async function startOAuthServer(): Promise { if (oauthServer) return oauthServer = createServer((req, res) => { @@ -124,7 +125,7 @@ async function startOAuthServer(): Promise { if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) { res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_CALLBACK) + res.end(HTML_CALLBACK) // kilocode_change - shared callback page is currently OpenCode-branded return } diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index db28ad3025d..27382b4fd40 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -312,7 +312,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { if (data.error === "slow_down") { // Based on the RFC spec, we must add 5 seconds to our current polling interval. - // (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5) + // (See https://www.rfc-editor.org/rfc/rfc8628.html#section-3.5) // kilocode_change - avoid unstable redirect let newInterval = (deviceData.interval + 5) * 1000 // GitHub OAuth API may return the new interval in seconds in the response. diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 8e6ebb9f837..8fefc88316b 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -130,7 +130,7 @@ async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: } } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -315,12 +315,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - -export const node = LayerNode.make(layer, [EventV2Bridge.node, Config.node, RuntimeFlags.node]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [EventV2Bridge.node, Config.node, RuntimeFlags.node], +}) export * as Plugin from "." diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 5a3a6fc9f93..fcda8564074 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -158,6 +158,7 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER, signal? return response.json() } +// kilocode_change start - retain Kilo-branded OAuth callback until the shared page supports Kilo branding const HTML_SUCCESS = ` @@ -253,6 +254,7 @@ export const renderOAuthError = (error: string) => `
` +// kilocode_change end interface PendingOAuth { pkce: PkceCodes @@ -313,7 +315,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } .catch((err) => current.reject(err)) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) - res.end(HTML_SUCCESS) + res.end(HTML_SUCCESS) // kilocode_change - shared callback page is currently OpenCode-branded return } diff --git a/packages/opencode/src/plugin/snowflake-cortex.ts b/packages/opencode/src/plugin/snowflake-cortex.ts index f7683899447..0eb0b2cbf14 100644 --- a/packages/opencode/src/plugin/snowflake-cortex.ts +++ b/packages/opencode/src/plugin/snowflake-cortex.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@kilocode/plugin" import { OAUTH_DUMMY_KEY } from "../auth" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { KiloOauthCallbackPage as OauthCallbackPage } from "@opencode-ai/core/kilocode/oauth/page" // kilocode_change - Kilo-branded callback page import { createServer } from "http" import open from "open" @@ -156,29 +157,6 @@ async function refreshAccessToken(account: string, refreshToken: string) { return token } -const HTML_SUCCESS = ` - - OpenCode - Snowflake Authorization Successful - -
-

Authorization Successful

-

You can close this window and return to OpenCode.

-
- - -` - -const htmlError = (message: string) => ` - - OpenCode - Snowflake Authorization Failed - -
-

Authorization Failed

-
${message}
-
- -` - async function startOAuthServer() { if (oauthServer) return @@ -203,7 +181,7 @@ async function startOAuthServer() { pendingOAuth?.reject(new Error(message)) pendingOAuth = undefined res.writeHead(400, { "Content-Type": "text/html" }) - res.end(htmlError(message)) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) return } @@ -214,7 +192,7 @@ async function startOAuthServer() { const message = errorDescription || error current.reject(new Error(message)) res.writeHead(200, { "Content-Type": "text/html" }) - res.end(htmlError(message)) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) return } @@ -222,7 +200,7 @@ async function startOAuthServer() { const message = "Missing authorization code" current.reject(new Error(message)) res.writeHead(400, { "Content-Type": "text/html" }) - res.end(htmlError(message)) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) return } @@ -231,7 +209,7 @@ async function startOAuthServer() { .catch((err) => current.reject(err instanceof Error ? err : new Error(String(err)))) res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_SUCCESS) + res.end(OauthCallbackPage.success({ provider: "Snowflake" })) }) await new Promise((resolve, reject) => { diff --git a/packages/opencode/src/plugin/tui/runtime.ts b/packages/opencode/src/plugin/tui/runtime.ts index 33df80295ed..a5cc7119fec 100644 --- a/packages/opencode/src/plugin/tui/runtime.ts +++ b/packages/opencode/src/plugin/tui/runtime.ts @@ -14,6 +14,7 @@ import { import path from "path" import { fileURLToPath } from "url" import { TuiConfig } from "@/config/tui" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { errorData, errorMessage } from "@opencode-ai/tui/util/error" import { isRecord } from "@opencode-ai/tui/util/record" import { resolveHostAttentionSoundPaths } from "@/config/tui-host-attention" @@ -1085,7 +1086,7 @@ async function load(input: { const flags = await Effect.runPromise( Effect.gen(function* () { return yield* RuntimeFlags.Service - }).pipe(Effect.provide(RuntimeFlags.defaultLayer)), + }).pipe(Effect.provide(AppNodeBuilder.build(RuntimeFlags.node))), ) const pluginOrigins = config.plugin_origins ?? (await TuiConfig.pluginOrigins()) const records = Flag.KILO_PURE ? [] : pluginOrigins diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts index fd42116d7ca..629099df1db 100644 --- a/packages/opencode/src/plugin/xai.ts +++ b/packages/opencode/src/plugin/xai.ts @@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@kilocode/plugin" import { OAUTH_DUMMY_KEY } from "../auth" import { createServer } from "http" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { escapeHtml } from "@/util/html" +import { escapeHtml } from "@/util/html" // kilocode_change - retained OAuth error page uses escaped diagnostics // Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from // non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships @@ -378,7 +378,6 @@ const HTML_ERROR = (error: string) => ` ` // kilocode_change end - // CORS allowlist for the loopback callback. The redirect_uri itself is // already bound to 127.0.0.1 and gated by PKCE+state, so we only accept // xAI's own auth origins for additional defense-in-depth on the OPTIONS @@ -429,7 +428,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.end(HTML_ERROR(errorMsg)) // kilocode_change - shared callback page is currently OpenCode-branded return } @@ -438,7 +437,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.end(HTML_ERROR(errorMsg)) // kilocode_change - shared callback page is currently OpenCode-branded return } @@ -447,7 +446,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.end(HTML_ERROR(errorMsg)) // kilocode_change - shared callback page is currently OpenCode-branded return } @@ -459,7 +458,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } .catch((err) => current.reject(err)) res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_SUCCESS) + res.end(HTML_SUCCESS) // kilocode_change - shared callback page is currently OpenCode-branded return } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 9655f367c45..9cb73f6ad71 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -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" @@ -17,7 +17,7 @@ import { Service } from "./bootstrap-service" export { Service } from "./bootstrap-service" export type { Interface } from "./bootstrap-service" -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { // Yield each bootstrap dep at layer init so `run` itself has R = never. @@ -56,31 +56,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer: Layer.Layer = layer.pipe( - Layer.provide([ - Config.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Plugin.defaultLayer, - Project.defaultLayer, - // kilocode_change start - KilocodeBootstrap.defaultLayer, - // ShareNext.defaultLayer, - // kilocode_change end - Snapshot.defaultLayer, - Vcs.defaultLayer, - ]), -) - -export const node = LayerNode.make(layer, [ - Config.node, - Format.node, - LSP.node, - Plugin.node, - Project.node, - KilocodeBootstrap.node, // kilocode_change - Snapshot.node, - Vcs.node, -]) +export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, KilocodeBootstrap.node, Snapshot.node, Vcs.node], // kilocode_change +}) export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-layer.ts b/packages/opencode/src/project/instance-layer.ts deleted file mode 100644 index a7e2bfcb7b6..00000000000 --- a/packages/opencode/src/project/instance-layer.ts +++ /dev/null @@ -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" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index a3d9e77d55f..071735e792e 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -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 { context as instanceContext, type InstanceContext } from "./instance-context" // kilocode_change import { InstanceBootstrap } from "./bootstrap-service" -import { InstanceBootstrap as InstanceBootstrapGraph } from "./bootstrap" import * as Project from "./project" export interface LoadInput { @@ -34,7 +34,7 @@ interface Entry { readonly deferred: Deferred.Deferred } -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const project = yield* Project.Service @@ -60,8 +60,9 @@ export const layer: Layer.Layer() const ready = bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx)) as Effect.Effect - yield* Effect.promise(() => instanceContext.provide(ctx, () => Effect.runPromise(ready))) + yield* Effect.promise(() => instanceContext.provide(ctx, () => Effect.runPromiseWith(runtime)(ready))) // kilocode_change end return ctx }).pipe(Effect.withSpan("InstanceStore.boot")) @@ -233,8 +234,12 @@ export const layer: Layer.Layer> export const Event = { - Updated: EventV2.define({ type: "project.updated", schema: Info.fields }), + Updated: Project.Event.Updated, } type Row = typeof ProjectTable.$inferSelect @@ -72,7 +44,7 @@ export function fromRow(row: Row): Info { return { id: row.id, worktree: row.worktree, - vcs: row.vcs ? Schema.decodeUnknownSync(ProjectVcs)(row.vcs) : undefined, + vcs: row.vcs ? Schema.decodeUnknownSync(Project.Vcs)(row.vcs) : undefined, name: row.name ?? undefined, icon, time: { @@ -88,15 +60,15 @@ export function fromRow(row: Row): Info { export const UpdateInput = Schema.Struct({ projectID: ProjectV2.ID, name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), + icon: Schema.optional(Project.Icon), + commands: Schema.optional(Project.Commands), }) export type UpdateInput = Types.DeepMutable> export const UpdatePayload = Schema.Struct({ name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), + icon: Schema.optional(Project.Icon), + commands: Schema.optional(Project.Commands), }).annotate({ identifier: "ProjectUpdateInput" }) export type UpdatePayload = Types.DeepMutable> @@ -131,11 +103,10 @@ export class Service extends Context.Service()("@opencode/Pr type GitResult = { code: number; text: string; stderr: string } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service - const proc = yield* AppProcess.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const projectV2 = yield* ProjectV2.Service const projectDirectories = yield* ProjectDirectories.Service @@ -168,7 +139,7 @@ export const layer = Layer.effect( }), ) - const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.KILO_FAKE_VCS) + const fakeVcs = Schema.decodeUnknownSync(Schema.optional(Project.Vcs))(Flag.KILO_FAKE_VCS) const scope = yield* Scope.Scope @@ -492,28 +463,21 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export const use = serviceUse(Service) -export const node = LayerNode.make(layer, [ - FSUtil.node, - AppProcess.node, - CrossSpawnSpawner.node, - ProjectV2.node, - ProjectDirectories.node, - EventV2Bridge.node, - RuntimeFlags.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + FSUtil.node, + AppProcess.node, + CrossSpawnSpawner.node, + ProjectV2.node, + ProjectDirectories.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, + ], +}) export * as Project from "./project" diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index decb3cfd989..eca56c0501a 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,11 +1,12 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" +import { Effect, Layer, Context, Schema, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" import { InstanceState } from "@/effect/instance-state" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Git } from "@/git" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" +import { VcsEvent } from "@opencode-ai/schema/vcs-event" const PATCH_CONTEXT_LINES = 2_147_483_647 const MAX_PATCH_BYTES = 10_000_000 @@ -234,14 +235,7 @@ const track = Effect.fnUntraced(function* ( export const Mode = Schema.Literals(["git", "branch"]) export type Mode = Schema.Schema.Type -export const Event = { - BranchUpdated: EventV2.define({ - type: "vcs.branch.updated", - schema: { - branch: Schema.optional(Schema.String), - }, - }), -} +export const Event = VcsEvent export const Info = Schema.Struct({ branch: Schema.optional(Schema.String), @@ -301,7 +295,7 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const git = yield* Git.Service @@ -424,8 +418,6 @@ export const layer: Layer.Layer("ProviderAuthMethod")({ type: Schema.Literals(["oauth", "api"]), label: Schema.String, - prompts: optionalOmitUndefined(Schema.Array(Prompt)), + prompts: optional(Schema.Array(Prompt)), }) {} export const Methods = Schema.Record(Schema.String, Schema.Array(Method)) @@ -112,14 +112,12 @@ export class Service extends Context.Service()("@opencode/Pr export const use = serviceUse(Service) -// kilocode_change start -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service const plugin = yield* Plugin.Service - const cache = yield* ModelCache.Service - // kilocode_change end + const cache = yield* ModelCache.Service // kilocode_change const state = yield* InstanceState.make( Effect.fn("ProviderAuth.state")(function* () { const plugins = yield* plugin.list() @@ -251,16 +249,6 @@ export const layer: Layer.Layer - layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelCache.defaultLayer), - ), -) -// kilocode_change end - -export const node = LayerNode.make(layer, [Auth.node, Plugin.node, ModelCache.node]) // kilocode_change +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Plugin.node, ModelCache.node] }) // kilocode_change export * as ProviderAuth from "./auth" diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 18c45450ba9..40b266edc99 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -7,7 +7,8 @@ import { Auth } from "../auth" import type { Provider } from "@opencode-ai/core/models-dev" import * as Log from "@opencode-ai/core/util/log" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" // kilocode_change type Models = Provider["models"] type KiloOptions = NonNullable[0]> @@ -326,14 +327,13 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(kiloModelsLayer), -) +export const defaultLayer: Layer.Layer = Layer.suspend(() => AppNodeBuilder.build(node)) // kilocode_change - build from the LayerNode graph -const kiloModels = LayerNode.make(kiloModelsLayer, []) -export const node = LayerNode.make(layer, [Auth.node, Config.node, kiloModels, httpClient]) +const kiloModels = LayerNode.make({ name: "kilo-models", layer: kiloModelsLayer, deps: [] }) +export const node = LayerNode.make({ + service: Service, + layer, + deps: [Auth.node, Config.node, kiloModels, httpClient], +}) export * as ModelCache from "./model-cache" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 61bd0d1a245..518b4b2e2ca 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -7,6 +7,7 @@ import { Context, Effect, Layer } from "effect" import { AI_SDK_PROVIDERS, KILO_OPENROUTER_BASE, PROMPTS } from "@kilocode/kilo-gateway" import { overlay } from "@/kilocode/anaconda-desktop/provider" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change export const Model = Core.Model export type Model = Core.Model @@ -99,14 +100,13 @@ export const layer: Layer.Layer = Layer.suspend(() => AppNodeBuilder.build(node)) // kilocode_change - build from the LayerNode graph -export const node = LayerNode.make(layer, [Core.node, Config.node, Auth.node, ModelCache.node]) +export const node = LayerNode.make({ + service: Service, + layer, + deps: [Core.node, Config.node, Auth.node, ModelCache.node], +}) export { AI_SDK_PROVIDERS, PROMPTS } export * as ModelsDev from "./models" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 20e53b517ac..897e8a6880f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -24,7 +24,7 @@ import { InstanceState } from "@/effect/instance-state" import { EffectPromise } from "@/effect/promise" import { FSUtil } from "@opencode-ai/core/fs-util" import { isRecord } from "@/util/record" -import { optionalOmitUndefined } from "@opencode-ai/core/schema" +import { optional, optionalOmitUndefined } from "@opencode-ai/core/schema" // kilocode_change import { ProviderTransform } from "./transform" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -1031,8 +1031,8 @@ const ProviderCost = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache: ProviderCacheCost, - tiers: optionalOmitUndefined(Schema.Array(ProviderCostTier)), - experimentalOver200K: optionalOmitUndefined( + tiers: optional(Schema.Array(ProviderCostTier)), + experimentalOver200K: optional( Schema.Struct({ input: Schema.Finite, output: Schema.Finite, @@ -1043,7 +1043,7 @@ const ProviderCost = Schema.Struct({ const ProviderLimit = Schema.Struct({ context: Schema.Finite, - input: optionalOmitUndefined(Schema.Finite), + input: optional(Schema.Finite), output: Schema.Finite, }) @@ -1060,7 +1060,7 @@ export const Model = Schema.Struct({ providerID: ProviderV2.ID, api: ProviderApiInfo, name: Schema.String, - family: optionalOmitUndefined(Schema.String), + family: optional(Schema.String), capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, @@ -1068,7 +1068,7 @@ export const Model = Schema.Struct({ options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, - variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), + variants: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), ...KILO_MODEL_SCHEMA_EXTENSIONS, // kilocode_change }).annotate({ identifier: "Model" }) export type Model = Types.DeepMutable> @@ -1079,7 +1079,7 @@ export const Info = Schema.Struct({ description: optionalOmitUndefined(Schema.String), // kilocode_change source: Schema.Literals(["env", "config", "custom", "api"]), env: Schema.Array(Schema.String), - key: optionalOmitUndefined(Schema.String), + key: optional(Schema.String), metadata: optionalOmitUndefined(ProviderMetadata), // kilocode_change options: Schema.Record(Schema.String, Schema.Any), models: Schema.Record(Schema.String, Model), @@ -1121,8 +1121,13 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass()("ProviderInitError", { providerID: ProviderV2.ID, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) { + override get message() { + return `Failed to initialize provider: ${this.providerID}` + } + static isInstance(input: unknown): input is InitError { return input instanceof InitError } } export class NoProvidersError extends Schema.TaggedErrorClass()("ProviderNoProvidersError", {}) { + override get message() { + return "No providers are available" + } + static isInstance(input: unknown): input is NoProvidersError { return input instanceof NoProvidersError } @@ -1146,6 +1159,10 @@ export class NoProvidersError extends Schema.TaggedErrorClass( export class NoModelsError extends Schema.TaggedErrorClass()("ProviderNoModelsError", { providerID: ProviderV2.ID, }) { + override get message() { + return `No models are available for provider: ${this.providerID}` + } + static isInstance(input: unknown): input is NoModelsError { return input instanceof NoModelsError } @@ -1330,7 +1347,7 @@ function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enabl .map((item) => item.id) } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -1942,49 +1959,53 @@ export const layer = Layer.effect( } } - const defaultPriority = [ - "claude-haiku-4-5", - "claude-haiku-4.5", - "3-5-haiku", - "3.5-haiku", - "gemini-3-flash", - "gemini-2.5-flash", - "gpt-5-nano", - ] - // kilocode_change - `let` (was upstream `const`) so the Kilo override below can reassign - let priority = providerID.startsWith("opencode") - ? ["gpt-5-nano"] - : providerID.startsWith("github-copilot") - ? ["gpt-5-mini", "claude-haiku-4.5", ...defaultPriority] - : defaultPriority - // kilocode_change start + // TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments. + if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) { + return undefined + } + + // kilocode_change start - Kilo's auto model is an ID, while upstream priorities are model families. const kiloPriority = kiloSmallModelPriority(providerID) - if (kiloPriority) priority = kiloPriority + if (kiloPriority) { + for (const id of kiloPriority) { + const model = provider.models[id] + if (model) return model + } + } // kilocode_change end - for (const item of priority) { + + const priority = providerID.startsWith("opencode") + ? ["gpt-nano"] + : providerID.startsWith("github-copilot") + ? ["gpt-mini", ...smallModelFamilyPriority] + : smallModelFamilyPriority + const models = sortBy( + Object.values(provider.models), + [(model) => model.release_date, "desc"], + [(model) => model.id, "desc"], + ) + for (const family of priority) { + const candidates = models.filter((model) => model.family === family) if (providerID === ProviderV2.ID.amazonBedrock) { const crossRegionPrefixes = ["global.", "us.", "eu."] - const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) - const globalMatch = candidates.find((m) => m.startsWith("global.")) - if (globalMatch) return provider.models[globalMatch] + const globalMatch = candidates.find((model) => model.id.startsWith("global.")) + if (globalMatch) return globalMatch const region = provider.options?.region if (region) { const regionPrefix = region.split("-")[0] if (regionPrefix === "us" || regionPrefix === "eu") { - const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) - if (regionalMatch) return provider.models[regionalMatch] + const regionalMatch = candidates.find((model) => model.id.startsWith(`${regionPrefix}.`)) + if (regionalMatch) return regionalMatch } } - const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) - if (unprefixed) return provider.models[unprefixed] - } else { - for (const model of Object.keys(provider.models)) { - if (model.includes(item)) return provider.models[model] - } + const unprefixed = candidates.find((model) => !crossRegionPrefixes.some((p) => model.id.startsWith(p))) + if (unprefixed) return unprefixed + continue } + if (candidates[0]) return candidates[0] } // kilocode_change start - fall back to kilo's auto small model @@ -2019,7 +2040,8 @@ export const layer = Layer.effect( return { providerID: entry.providerID, modelID: entry.modelID } } - const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)) + const configured = Object.keys(cfg.provider ?? {}) + const provider = Object.values(s.providers).find((p) => configured.length === 0 || configured.includes(p.id)) if (!provider) return yield* new NoProvidersError() const [model] = sort(Object.values(provider.models)) if (!model) return yield* new NoModelsError({ providerID: provider.id }) @@ -2033,19 +2055,8 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelsDev.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - ), -) - const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] +const smallModelFamilyPriority = ["gemini-flash", "gpt-nano", "claude-haiku"] export function sort(models: T[]) { return sortBy( models, @@ -2063,14 +2074,10 @@ export function parseModel(model: string) { } } -export const node = LayerNode.make(layer, [ - FSUtil.node, - Config.node, - Auth.node, - Env.node, - Plugin.node, - ModelsDev.node, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Config.node, Auth.node, Env.node, Plugin.node, ModelsDev.node, RuntimeFlags.node], +}) export * as Provider from "./provider" diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 11bb3b580d7..8112996020f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -471,7 +471,9 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re if ( options.store !== true && key && - ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(model.api.npm) + ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/github-copilot"].includes( + model.api.npm, + ) ) { msgs = mapProviderOptions(msgs, (options) => { if (!options?.[key] || !("itemId" in options[key])) return options @@ -625,6 +627,7 @@ function anthropicClaude5(apiId: string) { } // kilocode_change end + function anthropicAdaptiveEfforts(apiId: string): string[] | null { // kilocode_change start - include Claude 5+ models if (anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId)) { @@ -1588,6 +1591,16 @@ const SLUG_OVERRIDES: Record = { } export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { + const usesOpenAIReasoningGate = + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/azure" || + model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + const normalized = + usesOpenAIReasoningGate && + (model.capabilities.reasoning || options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) + ? { ...options, forceReasoning: true } + : options + if (model.api.npm === "@ai-sdk/gateway") { // Gateway providerOptions are split across two namespaces: // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.) @@ -1597,8 +1610,8 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a const i = model.api.id.indexOf("/") const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined - const gateway = options.gateway - const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway")) + const gateway = normalized.gateway + const rest = Object.fromEntries(Object.entries(normalized).filter(([k]) => k !== "gateway")) const has = Object.keys(rest).length > 0 const result: Record = {} @@ -1639,9 +1652,9 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a // providerOptions["openai"], but OpenAIResponsesLanguageModel checks // "azure" first. Pass both so model options work on either code path. if (model.api.npm === "@ai-sdk/azure") { - return { openai: options, azure: options } + return { openai: normalized, azure: normalized } } - return { [key]: options } + return { [key]: normalized } } export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number { diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index d4fb8afcb95..fa4762b5a8a 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,122 +1,29 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Schema, Context } from "effect" import { InstanceState } from "@/effect/instance-state" -import { SessionID, MessageID } from "@/session/schema" +import { SessionID } from "@/session/schema" import { QuestionID } from "./schema" import { KiloQuestion } from "@/kilocode/question" // kilocode_change import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" +import { QuestionV1 } from "@opencode-ai/schema/question-v1" -// Schemas — these are pure data; nothing checks class identity (see PR -// description) so they're plain `Schema.Struct` + type alias. That lets -// `Question.ask` and other internal sites trust the type contract without a -// re-decode to coerce nested class instances. - -export const Option = Schema.Struct({ - label: Schema.String.annotate({ - description: "Display text (1-5 words, concise)", - }), - description: Schema.String.annotate({ - description: "Explanation of choice", - }), - // kilocode_change start - optional i18n keys so clients can translate while still - // replying with the canonical English label (backend matches on `label`). - labelKey: Schema.optional(Schema.String).annotate({ - description: "Optional i18n key for the label; clients translate and still reply with `label`", - }), - descriptionKey: Schema.optional(Schema.String).annotate({ - description: "Optional i18n key for the description", - }), - // kilocode_change end - // kilocode_change start - hint to UI clients to switch the active agent/mode picker - // when this option is selected (before the reply is confirmed by the server). - mode: Schema.optional(Schema.String).annotate({ - description: "Optional agent/mode name to pre-select in the UI when this option is picked", - }), - // kilocode_change end -}).annotate({ identifier: "QuestionOption" }) -export type Option = Schema.Schema.Type - -const base = { - question: Schema.String.annotate({ - description: "Complete question", - }), - header: Schema.String.annotate({ - description: "Very short label (max 30 chars)", - }), - options: Schema.Array(Option).annotate({ - description: "Available choices", - }), - multiple: Schema.optional(Schema.Boolean).annotate({ - description: "Allow selecting multiple choices", - }), - // kilocode_change start - optional i18n keys for question text and header - questionKey: Schema.optional(Schema.String).annotate({ - description: "Optional i18n key for the question text; clients fall back to `question` when missing", - }), - headerKey: Schema.optional(Schema.String).annotate({ - description: "Optional i18n key for the header; clients fall back to `header` when missing", - }), - // kilocode_change end -} - -export const Info = Schema.Struct({ - ...base, - custom: Schema.optional(Schema.Boolean).annotate({ - description: "Allow typing a custom answer (default: true)", - }), -}).annotate({ identifier: "QuestionInfo" }) -export type Info = Schema.Schema.Type - -export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) -export type Prompt = Schema.Schema.Type - -export const Tool = Schema.Struct({ - messageID: MessageID, - callID: Schema.String, -}).annotate({ identifier: "QuestionTool" }) -export type Tool = Schema.Schema.Type - -export const Request = Schema.Struct({ - id: QuestionID, - sessionID: SessionID, - questions: Schema.Array(Info).annotate({ - description: "Questions to ask", - }), - blocking: Schema.optional(Schema.Boolean).annotate({ - // kilocode_change - description: "Whether this question blocks prompt input (default: true)", - }), - tool: Schema.optional(Tool), -}).annotate({ identifier: "QuestionRequest" }) -export type Request = Schema.Schema.Type - -export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) -export type Answer = Schema.Schema.Type - -export const Reply = Schema.Struct({ - answers: Schema.Array(Answer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}).annotate({ identifier: "QuestionReply" }) -export type Reply = Schema.Schema.Type - -export const Replied = Schema.Struct({ - sessionID: SessionID, - requestID: QuestionID, - answers: Schema.Array(Answer), -}).annotate({ identifier: "QuestionReplied" }) - -export const Rejected = Schema.Struct({ - sessionID: SessionID, - requestID: QuestionID, -}).annotate({ identifier: "QuestionRejected" }) - -export const Event = { - Asked: EventV2.define({ type: "question.asked", schema: Request.fields }), - Replied: EventV2.define({ type: "question.replied", schema: Replied.fields }), - Rejected: EventV2.define({ type: "question.rejected", schema: Rejected.fields }), -} +export const Option = QuestionV1.Option +export type Option = typeof Option.Type +export const Info = QuestionV1.Info +export type Info = typeof Info.Type +export const Prompt = QuestionV1.Prompt +export type Prompt = typeof Prompt.Type +export const Tool = QuestionV1.Tool +export type Tool = typeof Tool.Type +export const Request = QuestionV1.Request +export type Request = typeof Request.Type +export const Answer = QuestionV1.Answer +export type Answer = typeof Answer.Type +export const Reply = QuestionV1.Reply +export type Reply = typeof Reply.Type +export const Replied = QuestionV1.Replied +export const Rejected = QuestionV1.Rejected +export const Event = QuestionV1.Event export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { override get message() { @@ -272,8 +179,9 @@ export const layer = Layer.effect( }), ) +// kilocode_change - preserve legacy layer composition for Kilo callers export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) -export const node = LayerNode.make(layer, [EventV2Bridge.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as Question from "." diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 2574594a230..ed7f1edee7f 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,10 +1,4 @@ -import { Schema } from "effect" +import { QuestionV1 } from "@opencode-ai/schema/question-v1" -import { Identifier } from "@/id/id" -import { Newtype } from "@opencode-ai/core/schema" - -export class QuestionID extends Newtype()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) { - static ascending(id?: string): QuestionID { - return this.make(Identifier.ascending("question", id)) - } -} +export const QuestionID = QuestionV1.ID +export type QuestionID = typeof QuestionID.Type diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts index 9e31faf5725..9bb8a8c3b04 100644 --- a/packages/opencode/src/server/event.ts +++ b/packages/opencode/src/server/event.ts @@ -1,13 +1,7 @@ -import { EventV2 } from "@opencode-ai/core/event" import { Schema } from "effect" +import { ServerEvent } from "@opencode-ai/schema/server-event" -export const Event = { - Connected: EventV2.define({ type: "server.connected", schema: {} }), - Disposed: EventV2.define({ type: "global.disposed", schema: {} }), - // kilocode_change - emitted (via GlobalBus) when config updates without a full dispose; EventV2 def to - // keep this shared file off the legacy Bus. Only its .type string is used; publishers emit to GlobalBus. - ConfigUpdated: EventV2.define({ type: "global.config.updated", schema: {} }), -} +export const Event = ServerEvent export const InstanceDisposed = Schema.Struct({ id: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index c5c4e64e49d..98593e6a8fc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -1,5 +1,10 @@ import { Schema } from "effect" import { HttpApi } from "effect/unstable/httpapi" +import { EventV2 } from "@opencode-ai/core/event" +import { EventManifest } from "@/event-manifest" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { SkillV2 } from "@opencode-ai/core/skill" import { InstanceDisposed } from "@/server/event" import { Question } from "@/question" import { BusEvent } from "@/bus/bus-event" // kilocode_change - include legacy Kilo events until they migrate to EventV2 @@ -21,7 +26,6 @@ import { SessionApi } from "./groups/session" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" -import { Api } from "@opencode-ai/server/api" // kilocode_change start - Kilo HttpApi groups import { AgentBuilderApi } from "@/kilocode/server/httpapi/groups/agent-builder" import { BranchNameApi } from "@/kilocode/server/httpapi/groups/branch-name" @@ -42,12 +46,32 @@ import { SuggestionApi } from "@/kilocode/server/httpapi/groups/suggestion" import { TelemetryApi } from "@/kilocode/server/httpapi/groups/telemetry" import { MemoryApi } from "@/kilocode/server/httpapi/groups/memory" // kilocode_change // kilocode_change end -// GlobalEventSchema snapshots the registry after event-producing groups register their variants. +import { makeApi } from "@opencode-ai/protocol/api" +import { LocationMiddleware } from "@opencode-ai/server/location" +import { SessionLocationMiddleware } from "@opencode-ai/server/middleware/session-location" import { GlobalApi } from "./groups/global" import { Authorization } from "./middleware/authorization" import { SchemaErrorMiddleware } from "./middleware/schema-error" -const EventSchema = Schema.Union([...BusEvent.effectPayloads(), InstanceDisposed]).annotate({ identifier: "Event" }) // kilocode_change +const EventSchema = Schema.Union([ + ...EventManifest.Latest.values() + .map((definition) => + Schema.Struct({ + id: EventV2.ID, + type: Schema.Literal(definition.type), + properties: definition.data, + }).annotate({ identifier: `Event.${definition.type}` }), + ) + .toArray(), + ...BusEvent.effectPayloads(), // kilocode_change - include legacy Kilo events until they migrate to EventV2 + InstanceDisposed, +]).annotate({ identifier: "Event" }) + +export const ServerApi = makeApi({ + definitions: EventManifest.Latest.values().toArray(), + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) @@ -98,9 +122,18 @@ export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) .addHttpApi(EventApi) .addHttpApi(InstanceHttpApi) - .addHttpApi(Api) + .addHttpApi(ServerApi) .addHttpApi(PtyConnectApi) - .annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected]) + .annotate(HttpApi.AdditionalSchemas, [ + EventSchema, + Question.Replied, + Question.Rejected, + Credential.Value, + Integration.Inputs, + Integration.Method, + Integration.Ref, + SkillV2.Source, + ]) export type RootHttpApiType = typeof RootHttpApi export type InstanceHttpApiType = typeof InstanceHttpApi diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 800afef5976..1540f924f77 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,6 @@ -import { Config } from "@/config/config" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { EventV2 } from "@opencode-ai/core/event" +import { EventManifest } from "@/event-manifest" import { InstanceDisposed } from "@/server/event" import { BusEvent } from "@/bus/bus-event" // kilocode_change - include legacy Kilo events until they migrate to EventV2 import "@opencode-ai/core/account" @@ -15,16 +15,15 @@ const GlobalHealth = Schema.Struct({ version: Schema.String, }) -const SyncEventSchemas = EventV2.registry - .values() +const SyncEventSchemas = EventManifest.Latest.values() .flatMap((definition) => { - if (!definition.sync) return [] + if (!definition.durable) return [] return [ Schema.Struct({ type: Schema.Literal("sync"), id: EventV2.ID, syncEvent: Schema.Struct({ - type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)), + type: Schema.Literal(EventV2.versionedType(definition.type, definition.durable.version)), id: EventV2.ID, seq: Schema.Finite, aggregateID: Schema.String, @@ -41,6 +40,11 @@ const GlobalEventSchema = Schema.Struct({ workspace: Schema.optional(Schema.String), payload: Schema.Union([ ...BusEvent.effectPayloads(), // kilocode_change + ...EventManifest.Latest.values() + .map((definition) => + Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }), + ) + .toArray(), InstanceDisposed, ...SyncEventSchemas, ]), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index e9e63429db2..33c310cbfec 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -67,7 +67,7 @@ export const QuestionApi = HttpApi.make("question") ) .annotateMerge( OpenApi.annotations({ - title: "opencode HttpApi", + title: "Kilo HttpApi", // kilocode_change version: "0.0.1", description: "Effect HttpApi surface for instance routes.", }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 974376e895d..026fee106bc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,6 +1,6 @@ import * as InstanceState from "@/effect/instance-state" import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -14,7 +14,7 @@ import { InstanceHttpApi } from "../api" export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => Effect.gen(function* () { const ripgrep = yield* Ripgrep.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { return yield* effect.pipe( @@ -138,4 +138,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("content", content) .handle("status", status) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 538920b000e..6446c9d28bc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -6,7 +6,7 @@ import { Pty } from "@opencode-ai/core/pty" import { PtyProtocol } from "@opencode-ai/core/pty/protocol" import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Shell } from "@opencode-ai/core/shell" @@ -43,7 +43,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig const plugin = yield* Plugin.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const unregister = registerDisposer((directory) => Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) @@ -158,13 +158,13 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("remove", remove) .handle("connectToken", connectToken) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => Effect.gen(function* () { const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const unregister = registerDisposer((directory) => Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) @@ -270,4 +270,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne }), ) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index eba9b764589..a54c5454da7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,4 +1,5 @@ import { Image } from "@/image/image" // kilocode_change - classify user image validation defects +import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" // kilocode_change import { KiloSessionHttpApi } from "@/kilocode/server/httpapi/session-fork" // kilocode_change import { BlockedError as AgentRequirementError } from "@/kilocode/agent-requirements" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change @@ -322,13 +323,21 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) return Effect.void // kilocode_change - Stop is not an error return Effect.gen(function* () { - yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) const error = Cause.squash(cause) + // kilocode_change start - keep SQLite lock failures out of local CLI logs + const busy = isBusy(error) + if (busy) { + yield* Effect.logWarning("prompt_async database busy", { sessionID: ctx.params.sessionID }) + } + if (!busy) yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) + // kilocode_change end yield* events.publish(Session.Event.Error, { sessionID: ctx.params.sessionID, error: AgentRequirementError.isInstance(error) ? error.toObject() - : new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), + : busy // kilocode_change + ? new NamedError.Unknown({ message: busyMessage }).toObject() // kilocode_change + : new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), // kilocode_change }) }) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 21b53aba756..f1afbf91746 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -1,5 +1,6 @@ import { NamedError } from "@opencode-ai/core/util/error" import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error" +import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" // kilocode_change import { Cause, Effect } from "effect" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" @@ -16,6 +17,19 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) if (!defect) return Effect.failCause(cause) const error = defect.defect + // kilocode_change start - SQLite lock contention is expected with multiple local clients + if (isBusy(error)) { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logWarning("database busy", { ref }).pipe( + Effect.as( + HttpServerResponse.jsonUnsafe( + new NamedError.Unknown({ message: busyMessage, ref }).toObject(), + { status: 503 }, + ), + ), + ) + } + // kilocode_change end if ( ConfigErrorV1.JsonError.isInstance(error) || ConfigErrorV1.InvalidError.isInstance(error) || diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index b7dd0cceb2b..edb2812654a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -172,7 +172,7 @@ function matchLegacyOpenApi(input: Record) { normalizeLegacyErrorResponses(operation) } normalizeLegacyOperation(operation, path, method) - if ((path === "/event" || path === "/global/event") && method === "get") { + if ((path === "/event" || path === "/global/event" || path === "/api/event") && method === "get") { // HttpApi has no first-class SSE response schema, and these handlers are // raw/streaming routes. Document the actual wire protocol explicitly. operation.responses!["200"] = { @@ -182,7 +182,9 @@ function matchLegacyOpenApi(input: Record) { schema: path === "/event" ? { $ref: "#/components/schemas/Event" } - : { $ref: "#/components/schemas/GlobalEvent" }, + : path === "/global/event" + ? { $ref: "#/components/schemas/GlobalEvent" } + : { $ref: "#/components/schemas/V2Event" }, }, }, } diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 9aef07e5128..062890235e7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -61,22 +61,26 @@ import { MemoryService } from "@kilocode/kilo-memory/effect/service" // kilocode import { RuntimeFlags } from "@/effect/runtime-flags" import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { EventV2 } from "@opencode-ai/core/event" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Npm } from "@opencode-ai/core/npm" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectCopy } from "@opencode-ai/core/project/copy" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { lazy } from "@/util/lazy" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" -import { InstanceHttpApi, RootHttpApi } from "./api" -import { Api } from "@opencode-ai/server/api" +import { InstanceHttpApi, RootHttpApi, ServerApi } from "./api" import { PublicApi } from "./public" import { authorizationLayer, @@ -109,6 +113,10 @@ import { layer as referenceReconcilerLayer, locations as locationServiceMapLayer, } from "@/kilocode/server/reference-reconciler" // kilocode_change +import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services" +import { layer as locationLayer } from "@opencode-ai/server/location" +import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location" +import { PtyEnvironment } from "@opencode-ai/server/pty-environment" import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error" import { workspaceHandlers } from "./handlers/workspace" // kilocode_change start @@ -144,10 +152,10 @@ const cors = (corsOptions?: CorsOptions) => // - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth. // - instanceApiRoutes: remaining typed instance routes. // - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it. -const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) -const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) -const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) -const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) +const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.layer)) +const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer)) +const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer)) +const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer)) const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]), @@ -186,7 +194,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const instanceRoutes = instanceApiRoutes.pipe( Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]), ) -const serverRoutes = HttpApiBuilder.layer(Api).pipe( +const serverRoutes = HttpApiBuilder.layer(ServerApi).pipe( // kilocode_change start - effective references must be ready before any V2 location consumer runs Layer.provide(handlers.pipe(Layer.provide(locationServiceMapLayer), Layer.provide(referenceReconcilerLayer))), // kilocode_change end @@ -227,6 +235,7 @@ const app = LayerNode.group([ Npm.node, FSUtil.node, Database.node, + Credential.node, // kilocode_change Auth.node, Account.node, Config.node, @@ -245,6 +254,7 @@ const app = LayerNode.group([ Discovery.node, Question.node, Permission.node, + PermissionSaved.node, Todo.node, Session.node, SessionProjector.node, @@ -285,6 +295,8 @@ const app = LayerNode.group([ export function createRoutes( corsOptions?: CorsOptions, ): Layer.Layer { + const locationServiceMapV2 = buildLocationServiceMap() + return Layer.mergeAll( rootApiRoutes, eventApiRoutes, @@ -300,26 +312,39 @@ export function createRoutes( corsVaryFix, fenceLayer, cors(corsOptions), - Credential.defaultLayer, // kilocode_change MemoryService.layer, // kilocode_change - MoveSession.defaultLayer, // kilocode_change start AgentManager.defaultLayer, Notebook.defaultLayer, KiloViewers.defaultLayer, SyncEvent.defaultLayer, // kilocode_change end - EffectFlock.defaultLayer, // kilocode_change + AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]]), + AppNodeBuilderV1.build(EffectFlock.node), // kilocode_change HttpServer.layerServices, ]), - Layer.provide(LayerNode.buildLayer(app)), Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), - Layer.provide(Observability.layer), + Layer.provideMerge(Observability.layer), + + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), + Layer.provide(PtyEnvironment.layer), + Layer.provide( + AppNodeBuilderV1.build(SessionV2.node, [ + [LocationServiceMap.node, locationServiceMapV2], + [SessionExecution.node, SessionExecutionLocal.node], + ]), + ), + Layer.provide(locationServiceMapV2), + + Layer.provide(AppNodeBuilderV1.build(app)), ) } // kilocode_change start - keep listener routes local while application services come from AppRuntime export function createListenerRoutes(corsOptions?: CorsOptions) { + const locationServiceMapV2 = buildLocationServiceMap() + return Layer.mergeAll( rootApiRoutes, eventApiRoutes, @@ -328,7 +353,29 @@ export function createListenerRoutes(corsOptions?: CorsOptions) { serverRoutes, docRoute, uiRoute, - ).pipe(provideKiloListenerRoutes(corsOptions)) + ).pipe( + provideKiloListenerRoutes(corsOptions), + // Upstream's v2 ServerApi groups declare location/session middleware and services that must be + // satisfied when the layer is built, not at request time, so the listener needs the same chain + // createRoutes uses. + // + // These builds sit inside KiloListener's Layer.fresh boundary, so each one self-provides its own + // dependency subtree rather than resolving AppRuntime's. That is deliberate: SessionV2 is bound + // to this listener's LocationServiceMap and to SessionExecutionLocal, so it cannot be the + // process-wide instance. Everything the graph does not rebind (the nodes listed in AppLayer) + // still comes from AppRuntime, and the scope teardown releases the rest. + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), + Layer.provide(PtyEnvironment.layer), + Layer.provide( + AppNodeBuilderV1.build(SessionV2.node, [ + [LocationServiceMap.node, locationServiceMapV2], + [SessionExecution.node, SessionExecutionLocal.node], + ]), + ), + Layer.provide(AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]])), + Layer.provide(locationServiceMapV2), + ) } // kilocode_change end diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts index 7cbac4ed5f2..7e8eed89a4f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -1,4 +1,5 @@ import { Context, Effect, Layer, Option } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import * as Socket from "effect/unstable/socket/Socket" export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing") @@ -13,7 +14,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/HttpApiWebSocketTracker") {} -export const layer = Layer.sync(Service)(() => { +const layer = Layer.sync(Service)(() => { const sockets = new Set() let closing = false return Service.of({ @@ -44,6 +45,8 @@ export const layer = Layer.sync(Service)(() => { }) }) +export const node = LayerNode.make({ service: Service, layer, deps: [] }) + export const register = (close: Close) => Effect.gen(function* () { const tracker = yield* Effect.serviceOption(Service) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a82bf47e9ba..bad6e21faa2 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -2,6 +2,7 @@ import "./init-projectors" import { NodeHttpServer } from "@effect/platform-node" import { serverUrls } from "@/kilocode/cli/server-urls" // kilocode_change +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" @@ -113,7 +114,7 @@ function listenerLayer(opts: ListenOptions, port: number) { disableLogger: true, disableListenLog: true, }).pipe( - Layer.provideMerge(WebSocketTracker.layer), + Layer.provideMerge(AppNodeBuilder.build(WebSocketTracker.node)), Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })), // Install a fresh `ConfigProvider` per listener so `Config.string(...)` // reads reflect the current `process.env`. Effect's default diff --git a/packages/opencode/src/server/tui-event.ts b/packages/opencode/src/server/tui-event.ts index 73412b8778b..3fb3576db00 100644 --- a/packages/opencode/src/server/tui-event.ts +++ b/packages/opencode/src/server/tui-event.ts @@ -1,53 +1 @@ -import { SessionID } from "@/session/schema" -import { PositiveInt } from "@opencode-ai/core/schema" -import { EventV2 } from "@opencode-ai/core/event" -import { Effect, Schema } from "effect" - -const DEFAULT_TOAST_DURATION = 5000 - -export const TuiEvent = { - PromptAppend: EventV2.define({ type: "tui.prompt.append", schema: { text: Schema.String } }), - CommandExecute: EventV2.define({ - type: "tui.command.execute", - schema: { - command: Schema.Union([ - Schema.Literals([ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle", - ]), - Schema.String, - ]), - }, - }), - ToastShow: EventV2.define({ - type: "tui.toast.show", - schema: { - title: Schema.optional(Schema.String), - message: Schema.String, - variant: Schema.Literals(["info", "success", "warning", "error"]), - duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ - description: "Duration in milliseconds", - }), - }, - }), - SessionSelect: EventV2.define({ - type: "tui.session.select", - schema: { - sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), - }, - }), -} +export { TuiEvent } from "@opencode-ai/schema/tui-event" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 81aadddef0c..7614e371cff 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change import { SessionV1 } from "@opencode-ai/core/v1/session" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { Session } from "./session" @@ -13,7 +14,7 @@ import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { Effect, Layer, Context } from "effect" -import * as DateTime from "effect/DateTime" +import * as DateTime from "effect/DateTime" // kilocode_change import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { serviceUse } from "@opencode-ai/core/effect/service-use" @@ -26,22 +27,15 @@ import { KiloSession } from "@/kilocode/session" // kilocode_change end import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionEvent } from "@opencode-ai/core/session/event" // kilocode_change +import { SessionMessage } from "@opencode-ai/core/session/message" // kilocode_change import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { EventV2 } from "@opencode-ai/core/event" import { Database } from "@opencode-ai/core/database/database" // kilocode_change import { buildPrompt } from "@opencode-ai/core/session/compaction" +import { SessionCompactionEvent } from "@opencode-ai/schema/session-compaction-event" -export const Event = { - Compacted: EventV2.define({ - type: "session.compacted", - schema: { - sessionID: SessionID, - }, - }), -} +export const Event = SessionCompactionEvent export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 @@ -177,7 +171,7 @@ export class Service extends Context.Service()("@opencode/Se export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -706,30 +700,22 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Database.defaultLayer), // kilocode_change - ), -) +export const defaultLayer: Layer.Layer = Layer.suspend(() => AppNodeBuilder.build(node)) // kilocode_change - build from the LayerNode graph -export const node = LayerNode.make(layer, [ - Config.node, - Session.node, - Agent.node, - Plugin.node, - SessionProcessor.node, - Provider.node, - EventV2Bridge.node, - RuntimeFlags.node, - Database.node, // kilocode_change -]) +export const node = LayerNode.make({ + service: Service, + layer, + deps: [ + Config.node, + Session.node, + Agent.node, + Plugin.node, + SessionProcessor.node, + Provider.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, // kilocode_change + ], +}) export * as SessionCompaction from "./compaction" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 9609e8de3fc..ad8d44d862e 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import path from "path" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Effect, Layer, Context } from "effect" @@ -47,7 +47,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Instruction") {} -export const layer: Layer.Layer< +const layer: Layer.Layer< Service, never, FSUtil.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service @@ -262,18 +262,14 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export function loaded(messages: SessionV1.WithParts[]) { return extract(messages) } -export const node = LayerNode.make(layer, [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient], +}) export * as Instruction from "./instruction" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ff43537a2c3..aaaaedeabec 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { llmClient } from "@opencode-ai/core/effect/layer-node-platform" +import { llmClient } from "@opencode-ai/core/effect/app-node-platform" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Provider } from "@/provider/provider" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -9,7 +9,7 @@ import { Context, Effect, Layer } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" import type { LLMEvent } from "@opencode-ai/llm" -import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" +import { LLMClient } from "@opencode-ai/llm/route" import type { LLMClientService } from "@opencode-ai/llm/route" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" @@ -468,35 +468,24 @@ const live: Layer.Layer< }), ) -export const layer = live.pipe(Layer.provide(Permission.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer)) - -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide( - LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))), - ), - Layer.provide(RuntimeFlags.defaultLayer), - ), -) - // kilocode_change start - session export stream observer export { normalizeUsageForExport, observeFullStreamForExport } // kilocode_change end export const hasToolCalls = LLMRequestPrep.hasToolCalls -export const node = LayerNode.make(layer, [ - Auth.node, - Config.node, - Provider.node, - Plugin.node, - Permission.node, - EventV2Bridge.node, - llmClient, - RuntimeFlags.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: live, + deps: [ + Auth.node, + Config.node, + Provider.node, + Plugin.node, + Permission.node, + EventV2Bridge.node, + llmClient, + RuntimeFlags.node, + ], +}) export * as LLM from "./llm" diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 4bb5b81e70e..91da911f23c 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -192,6 +192,16 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre // kilocode_change end const tools = resolveTools(input) + // Codex parity: OpenAI Responses-family providers hardcode `strict: false` + // on every function tool so MCP-sourced and dynamic schemas that don't + // satisfy OpenAI's structured-outputs constraints still register. + if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + ) { + for (const key of Object.keys(tools)) tools[key] = { ...tools[key], strict: false } + } if ( input.model.providerID.includes("github-copilot") && Object.keys(tools).length === 0 && diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 845cecbda51..c56a8ec45dc 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,5 +1,4 @@ -import { EventV2 } from "@opencode-ai/core/event" -import { SessionID, MessageID, PartID } from "./schema" +import { SessionID, MessageID } from "./schema" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ProviderV2 } from "@opencode-ai/core/provider" import { @@ -12,17 +11,16 @@ import { Info, OutputLengthError, Part, - StructuredOutputError, SubtaskPart, User, WithParts, - type ToolPart, } from "@opencode-ai/core/v1/session" -export { EditorContext } from "@opencode-ai/core/v1/session" // kilocode_change +export { EditorContext } from "@/kilocode/editor-context" // kilocode_change import { NamedError } from "@opencode-ai/core/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { NotFoundError } from "@/storage/storage" import { and } from "drizzle-orm" import { desc } from "drizzle-orm" @@ -92,16 +90,7 @@ export const Event = { Updated: SessionV1.Event.MessageUpdated, Removed: SessionV1.Event.MessageRemoved, PartUpdated: SessionV1.Event.PartUpdated, - PartDelta: EventV2.define({ - type: "message.part.delta", - schema: { - sessionID: SessionID, - messageID: MessageID, - partID: PartID, - field: Schema.String, - delta: Schema.String, - }, - }), + PartDelta: SessionV1.Event.PartDelta, PartRemoved: SessionV1.Event.PartRemoved, } @@ -881,3 +870,4 @@ export function fromError( } export * as MessageV2 from "./message-v2" +export const node = LayerNode.group([Database.node]) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index d62e5d1acc8..77322c0f296 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -13,6 +13,7 @@ import { Session } from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" import { isOverflow } from "./overflow" +import { RuntimeFlags } from "@/effect/runtime-flags" // kilocode_change - configured output token ceiling import { PartID } from "./schema" import type { SessionID } from "./schema" import { SessionRetry } from "./retry" @@ -32,13 +33,7 @@ import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" -import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessage } from "@opencode-ai/core/session/message" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" -import * as DateTime from "effect/DateTime" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { ToolOutput, Usage, type LLMEvent } from "@opencode-ai/llm" +import { Usage, type LLMEvent } from "@opencode-ai/llm" const DOOM_LOOP_THRESHOLD = 3 export type Result = "compact" | "stop" | "continue" @@ -83,13 +78,10 @@ export interface Interface { } type ToolCall = { - assistantMessageID?: SessionMessage.ID partID: SessionV1.ToolPart["id"] messageID: SessionV1.ToolPart["messageID"] sessionID: SessionV1.ToolPart["sessionID"] done: Deferred.Deferred - inputEnded: boolean - raw: string } interface ProcessorContext extends Input { @@ -101,21 +93,19 @@ interface ProcessorContext extends Input { needsCompaction: boolean compactionError: ReturnType | undefined // kilocode_change currentText: SessionV1.TextPart | undefined - currentTextID: string | undefined reasoningMap: Record // kilocode_change start stepStart: number stepStartDate: number | undefined step: { reasoning: boolean; text: boolean; tool: boolean } // kilocode_change end - v2AssistantMessageID: SessionMessage.ID | undefined } type StreamEvent = LLMEvent export class Service extends Context.Service()("@opencode/SessionProcessor") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const session = yield* Session.Service @@ -130,8 +120,8 @@ export const layer = Layer.effect( const status = yield* SessionStatus.Service const image = yield* Image.Service const events = yield* EventV2Bridge.Service - const flags = yield* RuntimeFlags.Service const database = yield* Database.Service + const flags = yield* RuntimeFlags.Service // kilocode_change const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -156,7 +146,6 @@ export const layer = Layer.effect( needsCompaction: false, compactionError: undefined, // kilocode_change currentText: undefined, - currentTextID: undefined, reasoningMap: {}, // kilocode_change start telemetry: input.telemetry, @@ -164,9 +153,7 @@ export const layer = Layer.effect( stepStartDate: undefined, step: { reasoning: false, text: false, tool: false }, // kilocode_change end - v2AssistantMessageID: undefined, } - const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary let aborted = false const ac = new AbortController() // kilocode_change — abort controller for offline handler let attempt = KiloSessionProcessor.attempt() // kilocode_change @@ -192,34 +179,6 @@ export const layer = Layer.effect( if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore) }) - const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () { - if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID - ctx.v2AssistantMessageID = SessionMessage.ID.create() - yield* events.publish(SessionEvent.Step.Started, { - sessionID: ctx.sessionID, - assistantMessageID: ctx.v2AssistantMessageID, - agent: input.assistantMessage.agent, - model: { - id: ModelV2.ID.make(ctx.model.id), - providerID: ProviderV2.ID.make(ctx.model.providerID), - variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"), - }, - snapshot: ctx.snapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - return ctx.v2AssistantMessageID - }) - - const requireV2AssistantMessage = (toolCall?: ToolCall) => - toolCall?.assistantMessageID === undefined - ? Effect.die("V2 tool settlement has no owning assistant message") - : Effect.succeed(toolCall.assistantMessageID) - - const currentV2AssistantMessage = () => - ctx.v2AssistantMessageID === undefined - ? Effect.die("V2 step settlement has no owning assistant message") - : Effect.succeed(ctx.v2AssistantMessageID) - const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) { const call = ctx.toolcalls[toolCallID] if (!call) return undefined @@ -360,17 +319,6 @@ export const layer = Layer.effect( const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) { if (!(reasoningID in ctx.reasoningMap)) return - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Reasoning.Ended, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - reasoningID, - text: ctx.reasoningMap[reasoningID].text, - providerMetadata: ctx.reasoningMap[reasoningID].metadata, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() } @@ -378,33 +326,6 @@ export const layer = Layer.effect( delete ctx.reasoningMap[reasoningID] }) - const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () { - if (!mirrorAssistant) return - if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) { - yield* events.publish(SessionEvent.Text.Ended, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - textID: ctx.currentTextID, - text: ctx.currentText.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) => - currentV2AssistantMessage().pipe( - Effect.flatMap((assistantMessageID) => - events.publish(SessionEvent.Reasoning.Ended, { - sessionID: ctx.sessionID, - assistantMessageID, - reasoningID, - text: part.text, - providerMetadata: part.metadata, - timestamp: DateTime.makeUnsafe(Date.now()), - }), - ), - ), - ) - }) - const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: { id: string name: string @@ -425,17 +346,6 @@ export const layer = Layer.effect( } return { call: ctx.toolcalls[input.id], part } } - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - const assistantMessageID = mirrorAssistant ? yield* ensureV2AssistantMessage() : undefined - if (assistantMessageID) { - yield* events.publish(SessionEvent.Tool.Input.Started, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: input.id, - name: input.name, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } const part = yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -447,13 +357,10 @@ export const layer = Layer.effect( metadata: input.providerExecuted ? { providerExecuted: true } : undefined, } satisfies SessionV1.ToolPart) ctx.toolcalls[input.id] = { - assistantMessageID, done: yield* Deferred.make(), partID: part.id, messageID: part.messageID, sessionID: part.sessionID, - inputEnded: false, - raw: "", } return { call: ctx.toolcalls[input.id], part } }) @@ -486,16 +393,6 @@ export const layer = Layer.effect( switch (value.type) { case "reasoning-start": if (value.id in ctx.reasoningMap) return - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Reasoning.Started, { - sessionID: ctx.sessionID, - assistantMessageID: yield* ensureV2AssistantMessage(), - reasoningID: value.id, - providerMetadata: value.providerMetadata, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -514,15 +411,6 @@ export const layer = Layer.effect( ctx.reasoningMap[value.id].text += value.text if (value.text.trim()) ctx.step.reasoning = true // kilocode_change if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Reasoning.Delta, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - reasoningID: value.id, - delta: value.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* session.updatePartDelta({ sessionID: ctx.reasoningMap[value.id].sessionID, messageID: ctx.reasoningMap[value.id].messageID, @@ -549,82 +437,21 @@ export const layer = Layer.effect( yield* ensureToolCall(value) return - // kilocode_change start + // kilocode_change start - upstream calls ensureToolCall here, which creates a part when none + // exists and so resurrects a settled call as pending. Nothing else is needed from these two: + // tool-call carries the full input, and the v2 runner publishes the input events. case "tool-input-delta": - { - const toolCall = yield* readToolCall(value.id) - if (!toolCall) return - // kilocode_change end - const assistantMessageID = mirrorAssistant ? yield* requireV2AssistantMessage(toolCall.call) : undefined - if (assistantMessageID) { - yield* events.publish(SessionEvent.Tool.Input.Delta, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - delta: value.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - ctx.toolcalls[value.id] = { ...toolCall.call, raw: toolCall.call.raw + value.text } - } + case "tool-input-end": return - - // kilocode_change start - case "tool-input-end": { - const toolCall = yield* readToolCall(value.id) - if (!toolCall) return - // kilocode_change end - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) - yield* events.publish(SessionEvent.Tool.Input.Ended, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - text: toolCall.call.raw, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true } - return - } + // kilocode_change end case "tool-call": { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.name}`) } ctx.step.tool = true // kilocode_change - const toolCall = yield* ensureToolCall(value) + yield* ensureToolCall(value) const input = isRecord(value.input) ? value.input : { value: value.input } - if (!toolCall.call.inputEnded) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) - yield* events.publish(SessionEvent.Tool.Input.Ended, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - text: toolCall.call.raw, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - } - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) - yield* events.publish(SessionEvent.Tool.Called, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - tool: value.name, - input, - provider: { - executed: toolCall.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } // kilocode_change start - apply metadata buffered before the running transition const meta = ctx.toolmeta[value.id] yield* updateToolCall(value.id, (match) => ({ @@ -686,22 +513,6 @@ export const layer = Layer.effect( const toolCall = yield* readToolCall(value.id) if (!toolCall && value.result.type === "error") return if (value.result.type === "error") { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) - yield* events.publish(SessionEvent.Tool.Failed, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - error: { type: "unknown", message: errorMessage(value.result.value) }, - result: value.result, - provider: { - executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* failToolCall(value.id, value.result.value) return } @@ -734,57 +545,6 @@ export const layer = Layer.effect( : `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`, attachments: attachments.length ? attachments : undefined, } - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) - const content = [ - { type: "text" as const, text: output.output }, - ...(output.attachments?.map( - (item: SessionV1.FilePart) => - ({ - type: "file", - uri: item.url, - mime: item.mime, - name: item.filename, - }) as const, - ) ?? []), - ] - const unsupported = content.find((item) => item.type === "file" && !item.uri.startsWith("data:")) - if (unsupported?.type === "file") { - const error = new Error( - `Tool attachment URI "${unsupported.uri}" must be materialized before durable V2 settlement`, - ) - yield* events.publish(SessionEvent.Tool.Failed, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - error: { - type: "unknown", - message: error.message, - }, - provider: { - executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - yield* failToolCall(value.id, error) - return - } else - yield* events.publish(SessionEvent.Tool.Success, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - structured: output.metadata, - content, - result: value.result, - provider: { - executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* completeToolCall(value.id, output) // kilocode_change start - dismissed suggestions stop the turn after persisting normalized output if (output.metadata?.dismissed === true) { @@ -795,25 +555,6 @@ export const layer = Layer.effect( } case "tool-error": { - const toolCall = yield* readToolCall(value.id) - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) - yield* events.publish(SessionEvent.Tool.Failed, { - sessionID: ctx.sessionID, - assistantMessageID, - callID: value.id, - error: { - type: "unknown", - message: value.message, - }, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* failToolCall(value.id, value.error ?? new Error(value.message)) return } @@ -833,12 +574,6 @@ export const layer = Layer.effect( snapshotInitialization: input.snapshotInitialization, }) // kilocode_change end - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* ensureV2AssistantMessage() - } - } yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -852,7 +587,6 @@ export const layer = Layer.effect( case "step-finish": { // kilocode_change start - retry only terminally incomplete attempts before settlement if ( - !mirrorAssistant && KiloSessionProcessor.replayable({ finish: attempt.finish, text: attempt.text, @@ -891,9 +625,7 @@ export const layer = Layer.effect( // ctx.stepStart is 0 until `start-step` fires, which would feed a // huge bogus `elapsed` into telemetry. Fall back to now(). const endDate = Date.now() - const elapsedMs = Math.round( - performance.now() - (ctx.stepStart || performance.now()), - ) + const elapsedMs = Math.round(performance.now() - (ctx.stepStart || performance.now())) const startDate = ctx.stepStartDate ?? (Number.isFinite(elapsedMs) ? endDate - elapsedMs : endDate) const metrics = KiloSessionProcessor.computeMetrics({ providerMetadata: value.providerMetadata, @@ -909,21 +641,6 @@ export const layer = Layer.effect( telemetry: ctx.telemetry, }) // kilocode_change end - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Step.Ended, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - finish: value.reason, - cost: usage.cost, - tokens: usage.tokens, - snapshot: completedSnapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - ctx.v2AssistantMessageID = undefined - } - } ctx.assistantMessage.finish = value.reason // kilocode_change start - capture any subagent cost propagated by tool calls during this step (#6321) yield* reconcile() @@ -1009,17 +726,6 @@ export const layer = Layer.effect( } case "text-start": - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Text.Started, { - sessionID: ctx.sessionID, - assistantMessageID: yield* ensureV2AssistantMessage(), - timestamp: DateTime.makeUnsafe(Date.now()), - textID: value.id, - }) - } - } ctx.currentText = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -1029,7 +735,6 @@ export const layer = Layer.effect( time: { start: Date.now() }, metadata: value.providerMetadata, } - ctx.currentTextID = value.id yield* session.updatePart(ctx.currentText) return @@ -1038,15 +743,6 @@ export const layer = Layer.effect( ctx.currentText.text += value.text if (value.text.trim()) ctx.step.text = true // kilocode_change if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Text.Delta, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - textID: value.id, - delta: value.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } yield* session.updatePartDelta({ sessionID: ctx.currentText.sessionID, messageID: ctx.currentText.messageID, @@ -1073,18 +769,6 @@ export const layer = Layer.effect( attempt.text = true // kilocode_change ctx.step.text = true } // kilocode_change - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Text.Ended, { - sessionID: ctx.sessionID, - assistantMessageID: yield* currentV2AssistantMessage(), - text: ctx.currentText.text, - timestamp: DateTime.makeUnsafe(Date.now()), - textID: value.id, - }) - } - } { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } @@ -1092,7 +776,6 @@ export const layer = Layer.effect( if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata yield* session.updatePart(ctx.currentText) ctx.currentText = undefined - ctx.currentTextID = undefined return case "finish": @@ -1121,7 +804,6 @@ export const layer = Layer.effect( ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } yield* session.updatePart(ctx.currentText) ctx.currentText = undefined - ctx.currentTextID = undefined } for (const part of Object.values(ctx.reasoningMap)) { @@ -1143,16 +825,6 @@ export const layer = Layer.effect( const match = yield* readToolCall(toolCallID) if (!match) continue const part = match.part - if (mirrorAssistant && match.call.assistantMessageID) { - yield* events.publish(SessionEvent.Tool.Failed, { - sessionID: ctx.sessionID, - assistantMessageID: match.call.assistantMessageID, - callID: toolCallID, - error: { type: "unknown", message: "Tool execution aborted" }, - provider: { executed: part.metadata?.providerExecuted === true }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } const end = Date.now() const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {} yield* session.updatePart({ @@ -1199,7 +871,6 @@ export const layer = Layer.effect( if (e instanceof KiloSessionProcessor.IncompleteResponseError) ctx.assistantMessage.finish = "unknown" ctx.compactionError = MessageV2.ContextOverflowError.isInstance(error) ? error : ctx.compactionError // kilocode_change end - yield* flushV2Fragments() if (MessageV2.ContextOverflowError.isInstance(error)) { // respect compaction.auto === false by surfacing overflow as a hard error instead of auto-compacting if ((yield* config.get()).compaction?.auto === false && !ctx.assistantMessage.summary) { @@ -1213,20 +884,6 @@ export const layer = Layer.effect( yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } - if (!ctx.assistantMessage.summary) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (mirrorAssistant) { - yield* events.publish(SessionEvent.Step.Failed, { - sessionID: ctx.sessionID, - assistantMessageID: yield* ensureV2AssistantMessage(), - error: { - type: "unknown", - message: errorMessage(e), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - } ctx.assistantMessage.error = error yield* events.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, @@ -1266,35 +923,18 @@ export const layer = Layer.effect( action?: SessionRetry.Retryable["action"] next: number }) => { - const event = mirrorAssistant - ? events.publish(SessionEvent.Retried, { - sessionID: ctx.sessionID, - attempt: info.attempt, - error: { - message: info.message, - isRetryable: true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - : Effect.void - return flushV2Fragments().pipe( - Effect.andThen(event), - Effect.andThen( - status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - action: info.action, - next: info.next, - }), - ), - ) + return status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + action: info.action, + next: info.next, + }) } const request = () => Effect.gen(function* () { ctx.currentText = undefined - ctx.currentTextID = undefined ctx.reasoningMap = {} yield* status.set(ctx.sessionID, { type: "busy" }) ctx.step = { reasoning: false, text: false, tool: false } @@ -1356,7 +996,6 @@ export const layer = Layer.effect( { concurrency: 1 }, ) ctx.currentText = undefined - ctx.currentTextID = undefined ctx.reasoningMap = {} ctx.toolcalls = {} ctx.toolmeta = {} @@ -1376,7 +1015,6 @@ export const layer = Layer.effect( yield* request() }), replayable: () => - !mirrorAssistant && KiloSessionProcessor.replayable({ finish: attempt.finish, text: attempt.text, @@ -1389,10 +1027,7 @@ export const layer = Layer.effect( }) } - yield* recover().pipe( - Effect.catch(halt), - Effect.ensuring(cleanup()), - ) + yield* recover().pipe(Effect.catch(halt), Effect.ensuring(cleanup())) // kilocode_change end if (ctx.needsCompaction) return "compact" @@ -1417,38 +1052,24 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(LLM.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - ), -) - -export const node = LayerNode.make(layer, [ - Session.node, - Config.node, - Snapshot.node, - Agent.node, - LLM.node, - Permission.node, - Plugin.node, - SessionSummary.node, - SessionStatus.node, - Image.node, - EventV2Bridge.node, - RuntimeFlags.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Session.node, + Config.node, + Snapshot.node, + Agent.node, + LLM.node, + Permission.node, + Plugin.node, + SessionSummary.node, + SessionStatus.node, + Image.node, + EventV2Bridge.node, + Database.node, + RuntimeFlags.node, // kilocode_change + ], +}) export * as SessionProcessor from "./processor" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 20626cbd1fa..88b9aab899b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change import { PermissionV1 } from "@opencode-ai/core/v1/permission" import path from "path" import fs from "node:fs" // kilocode_change @@ -65,6 +66,9 @@ import { Truncate } from "@/tool/truncate" import { Image } from "@/image/image" import { decodeDataUrl } from "@/util/data-url" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" +import * as DateTime from "effect/DateTime" // kilocode_change +import { SessionEvent } from "@opencode-ai/core/session/event" // kilocode_change +import { SessionMessage } from "@opencode-ai/core/session/message" // kilocode_change import { InstanceState } from "@/effect/instance-state" import { InstanceRef } from "@/effect/instance-ref" import { Instance } from "@/kilocode/instance" @@ -75,13 +79,9 @@ import { SessionRunState } from "./run-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" -import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as KiloConfiguredReference from "@/kilocode/reference" // kilocode_change -import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/core/session/prompt" -import * as DateTime from "effect/DateTime" import { eq } from "drizzle-orm" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionReminders } from "./reminders" @@ -95,6 +95,14 @@ globalThis.AI_SDK_LOG_WARNINGS = false const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info) const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part) +const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024 +const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([ + "application/pdf", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]) const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. @@ -111,6 +119,17 @@ export const shouldAskPlanFollowup = KiloSessionPrompt.shouldAskPlanFollowup // // kilocode_change start - persistent tool-output pruning when payload is already large const REQUEST_PRUNE_BYTES = 1_250_000 // kilocode_change end +function mcpResourceBase64Size(value: string) { + const trimmed = value.replace(/\s/g, "") + const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0 + return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding) +} + +function formatMcpResourceBytes(value: number) { + if (value < 1024) return `${value} B` + if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB` + return `${Math.ceil(value / (1024 * 1024))} MB` +} function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { // cleanup() marks abandoned tool_use blocks this way after retries/aborts. // They are not pending work and must not trigger an assistant-prefill request. @@ -650,6 +669,7 @@ export const layer = Layer.effect( }, } yield* sessions.updatePart(part) + // kilocode_change start - dual-write the v2 shell record so the durable timeline correlates with the tool part if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Shell.Started, { sessionID: input.sessionID, @@ -659,6 +679,7 @@ export const layer = Layer.effect( command: input.command, }) } + // kilocode_change end return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -676,6 +697,7 @@ export const layer = Layer.effect( } if (timeout) output += "\n\n" + ["", timeout, ""].join("\n") // kilocode_change const completed = Date.now() + // kilocode_change start if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Shell.Ended, { sessionID: input.sessionID, @@ -684,6 +706,7 @@ export const layer = Layer.effect( output, }) } + // kilocode_change end if (!msg.time.completed) { msg.time.completed = completed yield* sessions.updateMessage(msg) @@ -694,7 +717,7 @@ export const layer = Layer.effect( time: { ...part.state.time, end: completed }, input: part.state.input, title: "", - metadata: { output, description: "" }, + metadata: { output }, output, } yield* sessions.updatePart(part) @@ -724,7 +747,7 @@ export const layer = Layer.effect( Effect.gen(function* () { output += chunk if (part.state.status === "running") { - part.state.metadata = { output, description: "" } + part.state.metadata = { output } yield* sessions.updatePart(part) } }), @@ -804,12 +827,6 @@ export const layer = Layer.effect( } yield* agents.guardRequirements(ag) // kilocode_change - enforce requirements before creating a turn - const current = yield* db - .select({ agent: SessionTable.agent, model: SessionTable.model }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get() - .pipe(Effect.orDie) const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) // kilocode_change start - retain the source session variant across Agent Manager's model-less fork handoff const stored = !input.model && !ag.model ? model : undefined @@ -843,28 +860,22 @@ export const layer = Layer.effect( editorContext: input.editorContext, // kilocode_change } - if (current?.agent !== info.agent) { - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(info.time.created), - agent: info.agent, - }) - } + const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) if ( - current?.model?.providerID !== info.model.providerID || - current.model.id !== info.model.modelID || - (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant + current.agent !== info.agent || + current.model?.providerID !== info.model.providerID || + current.model?.id !== info.model.modelID || + (current.model?.variant === "default" ? undefined : current.model?.variant) !== info.model.variant ) { - yield* events.publish(SessionEvent.ModelSwitched, { + yield* sessions.setAgentModel({ sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(info.time.created), + agent: info.agent, model: { - id: ModelV2.ID.make(info.model.modelID), - providerID: ProviderV2.ID.make(info.model.providerID), - variant: ModelV2.VariantID.make(info.model.variant ?? "default"), + id: info.model.modelID, + providerID: info.model.providerID, + variant: info.model.variant ?? "default", }, + time: info.time.created, }) } @@ -934,7 +945,8 @@ export const layer = Layer.effect( if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) const items = Array.isArray(content.contents) ? content.contents : [content.contents] for (const c of items) { - if ("text" in c && c.text) { + if (!c || typeof c !== "object") continue + if ("text" in c && typeof c.text === "string" && c.text) { pieces.push({ messageID: info.id, sessionID: input.sessionID, @@ -942,18 +954,47 @@ export const layer = Layer.effect( synthetic: true, text: c.text, }) - } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime + } else if ("blob" in c && typeof c.blob === "string" && c.blob) { + const mime = "mimeType" in c && typeof c.mimeType === "string" ? c.mimeType : part.mime + const filename = "uri" in c && typeof c.uri === "string" ? c.uri : part.filename + const size = mcpResourceBase64Size(c.blob) + if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) is not a supported attachment type]`, + }) + continue + } + if (size > MAX_MCP_RESOURCE_BLOB_BYTES) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) exceeds ${formatMcpResourceBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`, + }) + continue + } pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, - text: `[Binary content: ${mime}]`, + text: `[Binary MCP resource attached: ${filename ?? uri} (${mime})]`, + }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "file", + mime, + filename, + url: `data:${mime};base64,${c.blob}`, }) } } - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) } else { const error = Cause.squash(exit.cause) yield* Effect.logError("failed to read MCP resource", { error, clientName, uri }) @@ -1351,76 +1392,6 @@ export const layer = Layer.effect( yield* sessions.updateMessage(info) for (const part of parts) yield* sessions.updatePart(part) - const nextPrompt = parts.reduce( - (result, part) => { - if (part.type === "text") { - if (part.synthetic) result.synthetic.push(part.text) - else result.text.push(part.text) - } - if (part.type === "file") { - result.files.push( - new FileAttachment({ - uri: part.url, - mime: part.mime, - name: part.filename, - source: part.source - ? new Source({ - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - }) - : undefined, - }), - ) - } - if (part.type === "agent") { - result.agents.push( - new AgentAttachment({ - name: part.name, - source: part.source - ? new Source({ - start: part.source.start, - end: part.source.end, - text: part.source.value, - }) - : undefined, - }), - ) - } - return result - }, - { - text: [] as string[], - files: [] as FileAttachment[], - agents: [] as AgentAttachment[], - synthetic: [] as string[], - }, - ) - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { - yield* events.publish(SessionEvent.Prompted, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(info.time.created), - delivery: "steer", - prompt: new Prompt({ - text: nextPrompt.text.join("\n"), - files: nextPrompt.files, - agents: nextPrompt.agents, - }), - }) - } - for (const text of nextPrompt.synthetic) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { - yield* events.publish(SessionEvent.Synthetic, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(info.time.created), - text, - }) - } - } return { info, parts } }, Effect.scoped) @@ -1757,11 +1728,12 @@ export const layer = Layer.effect( // kilocode_change end // kilocode_change start - persistently prune stale tool outputs when payload is already large - const [skills, env, mem, instructions] = yield* Effect.all([ + const [skills, env, mem, instructions, mcpInstructions] = yield* Effect.all([ sys.skills(agent), sys.environment(model, lastUser.editorContext), // kilocode_change KiloSessionPrompt.memoryInject({ ctx, sessionID, record: step === 1, cache: memoryCache }), // kilocode_change instruction.system().pipe(Effect.orDie), + sys.mcp(agent, session.permission), ]) let modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model).pipe( Effect.provideService(Database.Service, database), @@ -1785,7 +1757,13 @@ export const layer = Layer.effect( yield* Effect.logWarning("payload still large after pruning", { "session.id": sessionID, size: nextSize }) } // kilocode_change end - const system = [...env, ...mem, ...instructions, ...(skills ? [skills] : [])] // kilocode_change + const system = [ + ...env, + ...mem, // kilocode_change + ...instructions, + ...(mcpInstructions ? [mcpInstructions] : []), + ...(skills ? [skills] : []), + ] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ @@ -1797,7 +1775,10 @@ export const layer = Layer.effect( sessionID, parentSessionID: session.parentID, system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])], + messages: [ + ...modelMsgs, + ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : []), + ], tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, @@ -2486,47 +2467,8 @@ export const layer = Layer.effect( }), ) -// kilocode_change start - keep prompt runtime requirements type-checked -export const defaultLayer: Layer.Layer = Layer.suspend(() => - // kilocode_change end - layer - .pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(SessionCompaction.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Question.defaultLayer), // kilocode_change - provide pending question dismissal dependency - Layer.provide(MCP.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Truncate.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), // kilocode_change - ) - .pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(Image.defaultLayer), // kilocode_change - provide user image normalization service - Layer.provide( - Layer.mergeAll( - EventV2Bridge.defaultLayer, - Agent.defaultLayer, - SystemPrompt.defaultLayer, - LLM.defaultLayer, - CrossSpawnSpawner.defaultLayer, - RuntimeFlags.defaultLayer, - ), - ), - ), -) +export const defaultLayer: Layer.Layer = Layer.suspend(() => AppNodeBuilder.build(node)) // kilocode_change - build from the LayerNode graph + const ModelRef = Schema.Struct({ providerID: ProviderV2.ID, modelID: ModelV2.ID, @@ -2662,37 +2604,41 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g -const repositoryCacheNode = LayerNode.make(RepositoryCache.defaultLayer, []) // kilocode_change +const repositoryCacheNode = RepositoryCache.node // kilocode_change -export const node = LayerNode.make(layer, [ - SessionStatus.node, - Session.node, - Agent.node, - Provider.node, - SessionProcessor.node, - SessionCompaction.node, - Plugin.node, - Command.node, - Config.node, - Permission.node, - FSUtil.node, - MCP.node, - LSP.node, - ToolRegistry.node, - Truncate.node, - Image.node, - CrossSpawnSpawner.node, - Instruction.node, - SessionRunState.node, - SessionRevert.node, - SessionSummary.node, - SystemPrompt.node, - LLM.node, - EventV2Bridge.node, - RuntimeFlags.node, - Database.node, - Question.node, // kilocode_change - repositoryCacheNode, // kilocode_change -]) +export const node = LayerNode.make({ + service: Service, + layer, + deps: [ + SessionStatus.node, + Session.node, + Agent.node, + Provider.node, + SessionProcessor.node, + SessionCompaction.node, + Plugin.node, + Command.node, + Config.node, + Permission.node, + FSUtil.node, + MCP.node, + LSP.node, + ToolRegistry.node, + Truncate.node, + Image.node, + CrossSpawnSpawner.node, + Instruction.node, + SessionRunState.node, + SessionRevert.node, + SessionSummary.node, + SystemPrompt.node, + LLM.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, + Question.node, // kilocode_change + repositoryCacheNode, // kilocode_change + ], +}) export * as SessionPrompt from "./prompt" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index a2d6801175e..0af271a7ded 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -27,7 +27,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionRevert") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Session.Service @@ -197,26 +197,18 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(Config.defaultLayer), // kilocode_change - ), -) - -export const node = LayerNode.make(layer, [ - Session.node, - Snapshot.node, - Storage.node, - EventV2Bridge.node, - SessionSummary.node, - SessionRunState.node, - Config.node, // kilocode_change -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Session.node, + Snapshot.node, + Storage.node, + EventV2Bridge.node, + SessionSummary.node, + SessionRunState.node, + Config.node, // kilocode_change + ], +}) export * as SessionRevert from "./revert" diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 9c851916104..e2666882466 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change import { InstanceState } from "@/effect/instance-state" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Runner } from "@/effect/runner" @@ -108,10 +109,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), -) +export const defaultLayer: Layer.Layer = Layer.suspend(() => AppNodeBuilder.build(node)) // kilocode_change - build from the LayerNode graph const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(function* ( background: BackgroundJob.Interface, @@ -151,6 +149,6 @@ function busyError(sessionID: SessionID) { return new Session.BusyError({ sessionID }) } -export const node = LayerNode.make(layer, [BackgroundJob.node, SessionStatus.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [BackgroundJob.node, SessionStatus.node] }) export * as SessionRunState from "./run-state" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 4a49d110c8c..162c2a4f45a 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -2,14 +2,14 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" import { SessionV2 } from "@opencode-ai/core/session" -import { withStatics } from "@opencode-ai/core/schema" +import { statics } from "@opencode-ai/core/schema" export const SessionID = SessionV2.ID export type SessionID = Schema.Schema.Type export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( Schema.brand("MessageID"), - withStatics((s) => ({ + statics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), })), ) @@ -18,7 +18,7 @@ export type MessageID = Schema.Schema.Type export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( Schema.brand("PartID"), - withStatics((s) => ({ + statics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), })), ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 832f87ae409..87c5e2fb7a3 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Slug } from "@opencode-ai/core/util/slug" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -27,6 +28,7 @@ import { Snapshot } from "@/snapshot" import { ProjectV2 } from "@opencode-ai/core/project" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" +import { SessionMessage } from "@opencode-ai/core/session/message" // kilocode_change - shared Revert.State brand import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" @@ -50,7 +52,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const runtime = makeRuntime(Database.Service, Database.defaultLayer) +const runtime = makeRuntime(Database.Service, AppNodeBuilder.build(Database.node)) const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " @@ -74,7 +76,17 @@ export function fromRow(row: SessionRow): Info { } : undefined const share = row.share_url ? { url: row.share_url } : undefined - const revert = row.revert ?? undefined + // kilocode_change start - the shared column stores the upstream Revert.State brand; project it to the v1 shape + const revert = row.revert + ? { + messageID: MessageID.make(row.revert.messageID), + partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined, + snapshot: row.revert.snapshot, + diff: row.revert.diff, + workspace: row.revert.workspace, + } + : undefined + // kilocode_change end return { id: row.id, slug: row.slug, @@ -142,7 +154,10 @@ export function toRow(info: Info) { tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning, tokens_cache_read: (info.tokens ?? EmptyTokens).cache.read, tokens_cache_write: (info.tokens ?? EmptyTokens).cache.write, - revert: info.revert ?? null, + // kilocode_change - re-brand the v1 messageID to the shared Revert.State brand for the column + revert: info.revert + ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } + : null, permission: info.permission, time_created: info.time.created, time_updated: info.time.updated, @@ -515,6 +530,12 @@ export interface Interface { readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect + readonly setAgentModel: (input: { + sessionID: SessionID + agent: string + model: NonNullable + time: number + }) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: PermissionV1.Ruleset }) => Effect.Effect readonly setRevert: (input: { sessionID: SessionID @@ -826,9 +847,7 @@ export const layer: Layer.Layer< // kilocode_change start - historical forks must use the model from retained context, not a later source-session selection const msgs = yield* messages({ sessionID: input.sessionID }) const point = input.messageID - const message = point - ? msgs.findLast((msg) => msg.info.id < point && msg.info.role === "user") - : undefined + const message = point ? msgs.findLast((msg) => msg.info.id < point && msg.info.role === "user") : undefined const model = message?.info.role === "user" ? { @@ -921,6 +940,19 @@ export const layer: Layer.Layer< yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } }).pipe(Effect.orDie) }) + const setAgentModel = Effect.fn("Session.setAgentModel")(function* (input: { + sessionID: SessionID + agent: string + model: NonNullable + time: number + }) { + yield* patch(input.sessionID, { + agent: input.agent, + model: input.model, + time: { updated: input.time }, + }).pipe(Effect.orDie) + }) + const setPermission = Effect.fn("Session.setPermission")(function* (input: { sessionID: SessionID permission: PermissionV1.Ruleset @@ -1059,6 +1091,7 @@ export const layer: Layer.Layer< setTitle, setArchived, setMetadata, + setAgentModel, setPermission, setRevert, clearRevert, @@ -1080,14 +1113,7 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(SessionExecution.noopLayer), - Layer.provide(SessionV2.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) +export const defaultLayer: Layer.Layer = Layer.suspend(() => AppNodeBuilder.build(node)) // kilocode_change - build from the LayerNode graph const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* ( background: BackgroundJob.Interface, @@ -1190,6 +1216,10 @@ export function listGlobal(input?: { // kilocode_change - delegate the exported Promise facade to the Kilo session runtime export const fork = kiloSessionFork -export const node = LayerNode.make(layer, [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node]) +export const node = LayerNode.make({ + service: Service, + layer, + deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], +}) export * as Session from "./session" diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 6facd8c1df3..21550638678 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -1,61 +1,14 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" -import { QuestionID } from "@/question/schema" // kilocode_change -import { NonNegativeInt } from "@opencode-ai/core/schema" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context } from "effect" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" +import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" -export const Info = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("idle"), - }), - Schema.Struct({ - type: Schema.Literal("retry"), - attempt: NonNegativeInt, - message: Schema.String, - action: Schema.optional( - Schema.Struct({ - reason: Schema.String, - provider: Schema.String, - title: Schema.String, - message: Schema.String, - label: Schema.String, - link: Schema.optional(Schema.String), - }), - ), - next: NonNegativeInt, - }), - Schema.Struct({ - type: Schema.Literal("busy"), - }), - // kilocode_change start - Schema.Struct({ - type: Schema.Literal("offline"), - requestID: QuestionID, - message: Schema.String, - }), - // kilocode_change end -]).annotate({ identifier: "SessionStatus" }) -export type Info = Schema.Schema.Type +export const Info = SessionStatusEvent.Info +export type Info = SessionStatusEvent.Info -export const Event = { - Status: EventV2.define({ - type: "session.status", - schema: { - sessionID: SessionID, - status: Info, - }, - }), - // deprecated - Idle: EventV2.define({ - type: "session.idle", - schema: { - sessionID: SessionID, - }, - }), -} +export const Event = SessionStatusEvent export interface Interface { readonly get: (sessionID: SessionID) => Effect.Effect @@ -98,8 +51,9 @@ export const layer = Layer.effect( }), ) +// kilocode_change - preserve legacy layer composition for Kilo callers export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) -export const node = LayerNode.make(layer, [EventV2Bridge.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as SessionStatus from "./status" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index d540de86173..dc98645f6b2 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -73,7 +73,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionSummary") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Session.Service @@ -179,28 +179,16 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Storage.defaultLayer), // kilocode_change - ), -) - export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), }) export type DiffInput = Schema.Schema.Type -export const node = LayerNode.make(layer, [ - Session.node, - Snapshot.node, - EventV2Bridge.node, - Config.node, - Storage.node, // kilocode_change -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Session.node, Snapshot.node, EventV2Bridge.node, Config.node, Storage.node], // kilocode_change +}) export * as SessionSummary from "./summary" diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 7922e843305..eb6fdaa357c 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -20,9 +20,11 @@ import { Permission } from "@/permission" import { Skill } from "@/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" +import { MCP } from "@/mcp" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { PluginV2 } from "@opencode-ai/core/plugin" // kilocode_change // kilocode_change start import SOUL from "../kilocode/soul.txt" @@ -90,15 +92,17 @@ export function provider(model: Provider.Model) { export interface Interface { readonly environment: (model: Provider.Model, editorContext?: EditorContext) => Effect.Effect // kilocode_change readonly skills: (agent: Agent.Info) => Effect.Effect + readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect } export class Service extends Context.Service()("@opencode/SystemPrompt") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const skill = yield* Skill.Service - const locations = yield* LocationServiceMap + const mcp = yield* MCP.Service + const locations = yield* LocationServiceMap.Service const config = yield* Config.Service // kilocode_change return Service.of({ @@ -110,7 +114,9 @@ export const layer = Layer.effect( const ctx = yield* InstanceState.context const cfg = yield* config.get() const references = yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() + if (Object.keys(cfg.references ?? cfg.reference ?? {}).length) { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) + } yield* KiloReference.sync({ references: cfg.references ?? cfg.reference ?? {}, directory: ctx.directory, @@ -155,18 +161,38 @@ export const layer = Layer.effect( Skill.fmt(list, { verbose: true }), ].join("\n") }), + + mcp: Effect.fn("SystemPrompt.mcp")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) { + const ruleset = Permission.merge(agent.permission, permission ?? []) + const instructions = (yield* mcp.instructions()).filter( + (item) => item.tools.length === 0 || Permission.disabled(item.tools, ruleset).size < item.tools.length, + ) + if (instructions.length === 0) return + + return [ + "", + ...instructions.flatMap((item) => [ + ` `, + ...item.instructions.split("\n").map((line) => ` ${line}`), + " ", + ]), + "", + ].join("\n") + }), }) }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(Config.defaultLayer), // kilocode_change -) +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) -const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, []) - -export const node = LayerNode.make(layer, [Skill.node, locationServiceMapNode, Config.node]) // kilocode_change +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Skill.node, MCP.node, Config.node, locationServiceMapNode], // kilocode_change +}) export * as SystemPrompt from "./system" diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 6e9eeba62b8..92617fc0028 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,34 +1,21 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionID } from "./schema" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context } from "effect" import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { asc } from "drizzle-orm" import { TodoTable } from "@opencode-ai/core/session/sql" import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" +import { SessionTodo } from "@opencode-ai/schema/session-todo" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change -export const Info = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}).annotate({ identifier: "Todo" }) -export type Info = Schema.Schema.Type +export const Info = SessionTodo.Info +export type Info = SessionTodo.Info -export const Event = { - Updated: EventV2.define({ - type: "todo.updated", - schema: { - sessionID: SessionID, - todos: Schema.Array(Info), - }, - }), -} +export const Event = SessionTodo.Event export interface Interface { - readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect + readonly update: (input: { sessionID: SessionID; todos: ReadonlyArray }) => Effect.Effect readonly get: (sessionID: SessionID) => Effect.Effect } @@ -40,7 +27,7 @@ export const layer = Layer.effect( const events = yield* EventV2Bridge.Service const { db } = yield* Database.Service - const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) { + const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: ReadonlyArray }) { yield* db .transaction((tx) => Effect.gen(function* () { @@ -83,8 +70,9 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer)) +// kilocode_change - preserve legacy layer composition for Kilo callers +export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppNodeBuilder.build(Database.node))) -export const node = LayerNode.make(layer, [EventV2Bridge.node, Database.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node, Database.node] }) export * as Todo from "./todo" diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 98bb1a11e4b..2d7f45fbf2a 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -28,6 +28,21 @@ import { SwePruner } from "@/kilocode/swe-pruner" import { Config } from "@/config/config" import { PermissionProvenance } from "@/kilocode/permission/provenance" // kilocode_change end +import { isRecord } from "@/util/record" + +const MCP_RESOURCE_TOOLS = { + list: "list_mcp_resources", + listTemplates: "list_mcp_resource_templates", + read: "read_mcp_resource", +} as const +const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024 +const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([ + "application/pdf", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]) export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { agent: Agent.Info @@ -159,101 +174,464 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) } - const mcpTools = (yield* SandboxPolicy.networkRestricted(input.session.id)) ? {} : yield* mcp.tools() // kilocode_change - for (const [key, item] of Object.entries(mcpTools)) { // kilocode_change - const execute = item.execute - if (!execute) continue + const restricted = yield* SandboxPolicy.networkRestricted(input.session.id) // kilocode_change + const hasMcpResourceServer = Object.values(yield* mcp.clients()).some( + (client) => !!client.getServerCapabilities()?.resources, + ) + if (!restricted && hasMcpResourceServer) { + tools[MCP_RESOURCE_TOOLS.list] = tool({ + description: + "Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.", + inputSchema: jsonSchema( + ProviderTransform.schema(input.model, { + type: "object", + properties: { + server: { + type: "string", + description: "Optional MCP server name. When omitted, lists resources from every connected server.", + }, + }, + additionalProperties: false, + }), + ), + execute(args, opts) { + return run.promise( + Effect.gen(function* () { + const parsed = parseListMcpResourcesArgs(args) + const ctx = context(toRecord(args), opts) + const clients = yield* mcp.clients() + const resourceServers = Object.entries(clients) + .filter((entry) => !!entry[1].getServerCapabilities()?.resources) + .map((entry) => entry[0]) + .sort((a, b) => a.localeCompare(b)) + if (parsed.server && !resourceServers.includes(parsed.server)) { + throw new Error( + resourceServers.length === 0 + ? `MCP server "${parsed.server}" does not support resources` + : `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`, + ) + } + const permissionPatterns = parsed.server + ? [`mcp:${parsed.server}:*`] + : resourceServers.map((server) => `mcp:${server}:*`) + yield* plugin.trigger( + "tool.execute.before", + { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* ctx.ask({ + permission: "read", + metadata: parsed.server ? { server: parsed.server } : {}, + patterns: permissionPatterns, + always: permissionPatterns, + }) - const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema)) - const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} }) - item.inputSchema = jsonSchema(transformed) - item.execute = (args, opts) => - run.promise( - Effect.gen(function* () { - const ctx = context(args, opts) - yield* plugin.trigger( - "tool.execute.before", - { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, - { args }, - ) - // kilocode_change start - const result: Awaited>> = yield* SandboxPolicy.executeMcp( - ctx.sessionID, - item, - Effect.gen(function* () { - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - return yield* Effect.promise(() => execute(args, opts)) - }), - ).pipe( - // kilocode_change end - Effect.withSpan("Tool.execute", { - attributes: { - "tool.name": key, - "tool.call_id": opts.toolCallId, - "session.id": ctx.sessionID, - "message.id": input.processor.message.id, + const resources = Object.values(yield* mcp.resources(parsed.server)) + const filtered = resources + .filter((resource) => !parsed.server || resource.client === parsed.server) + .toSorted((a, b) => + (a.client + "\u0000" + a.name + "\u0000" + a.uri).localeCompare( + b.client + "\u0000" + b.name + "\u0000" + b.uri, + ), + ) + const content = JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2) + const truncated = yield* truncate.output(content, {}, input.agent) + const output = { + title: parsed.server ? `MCP resources: ${parsed.server}` : "MCP resources", + metadata: { + count: filtered.length, + servers: resourceServers, + ...(parsed.server ? { server: parsed.server } : {}), + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), }, - }), - ) - yield* plugin.trigger( - "tool.execute.after", - { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, - result, - ) + output: truncated.content, + } + yield* plugin.trigger( + "tool.execute.after", + { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + output, + ) + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + }), + ) + }, + }) - const textParts: string[] = [] - const attachments: Omit[] = [] - for (const contentItem of result.content) { - if (contentItem.type === "text") textParts.push(contentItem.text) - else if (contentItem.type === "image") { + tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({ + description: + "Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.", + inputSchema: jsonSchema( + ProviderTransform.schema(input.model, { + type: "object", + properties: { + server: { + type: "string", + description: + "Optional MCP server name. When omitted, lists resource templates from every connected server.", + }, + }, + additionalProperties: false, + }), + ), + execute(args, opts) { + return run.promise( + Effect.gen(function* () { + const parsed = parseListMcpResourcesArgs(args) + const ctx = context(toRecord(args), opts) + const clients = yield* mcp.clients() + const resourceServers = Object.entries(clients) + .filter((entry) => !!entry[1].getServerCapabilities()?.resources) + .map((entry) => entry[0]) + .sort((a, b) => a.localeCompare(b)) + if (parsed.server && !resourceServers.includes(parsed.server)) { + throw new Error( + resourceServers.length === 0 + ? `MCP server "${parsed.server}" does not support resources` + : `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`, + ) + } + const permissionPatterns = parsed.server + ? [`mcp:${parsed.server}:*`] + : resourceServers.map((server) => `mcp:${server}:*`) + yield* plugin.trigger( + "tool.execute.before", + { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* ctx.ask({ + permission: "read", + metadata: parsed.server ? { server: parsed.server } : {}, + patterns: permissionPatterns, + always: permissionPatterns, + }) + + const templates = Object.values(yield* mcp.resourceTemplates(parsed.server)) + const filtered = templates + .filter((template) => !parsed.server || template.client === parsed.server) + .toSorted((a, b) => + (a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare( + b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate, + ), + ) + const content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2) + const truncated = yield* truncate.output(content, {}, input.agent) + const output = { + title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates", + metadata: { + count: filtered.length, + servers: resourceServers, + ...(parsed.server ? { server: parsed.server } : {}), + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + output: truncated.content, + } + yield* plugin.trigger( + "tool.execute.after", + { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + output, + ) + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + }), + ) + }, + }) + + tools[MCP_RESOURCE_TOOLS.read] = tool({ + description: + "Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.", + inputSchema: jsonSchema( + ProviderTransform.schema(input.model, { + type: "object", + properties: { + server: { + type: "string", + description: "MCP server name exactly as returned by list_mcp_resources.", + }, + uri: { + type: "string", + description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.", + }, + }, + required: ["server", "uri"], + additionalProperties: false, + }), + ), + execute(args, opts) { + return run.promise( + Effect.gen(function* () { + const parsed = parseReadMcpResourceArgs(args) + const ctx = context(toRecord(args), opts) + const clients = yield* mcp.clients() + const client = clients[parsed.server] + if (!client) { + throw new Error(`MCP server "${parsed.server}" is not connected`) + } + if (!client.getServerCapabilities()?.resources) { + throw new Error(`MCP server "${parsed.server}" does not support resources`) + } + yield* plugin.trigger( + "tool.execute.before", + { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* ctx.ask({ + permission: "read", + metadata: { server: parsed.server, uri: parsed.uri }, + patterns: [`mcp:${parsed.server}:${parsed.uri}`], + always: [`mcp:${parsed.server}:*`], + }) + + const content = yield* mcp.readResource(parsed.server, parsed.uri) + if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`) + + const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content) + const truncated = yield* truncate.output(formatted.text, {}, input.agent) + const output = { + title: `MCP resource: ${parsed.uri}`, + metadata: { + server: parsed.server, + uri: parsed.uri, + contents: formatted.contents, + attachments: formatted.attachments.length, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + output: truncated.content, + attachments: formatted.attachments.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), + } + yield* plugin.trigger( + "tool.execute.after", + { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + output, + ) + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + }), + ) + }, + }) + } + + const mcpTools = restricted ? {} : yield* mcp.tools() // kilocode_change + + for (const [key, item] of Object.entries(mcpTools)) { // kilocode_change + const execute = item.execute + if (!execute) continue + + const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema)) + const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} }) + item.inputSchema = jsonSchema(transformed) + item.execute = (args, opts) => + run.promise( + Effect.gen(function* () { + const ctx = context(args, opts) + yield* plugin.trigger( + "tool.execute.before", + { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + // kilocode_change start + const result: Awaited>> = yield* SandboxPolicy.executeMcp( + ctx.sessionID, + item, + Effect.gen(function* () { + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) + return yield* Effect.promise(() => execute(args, opts)) + }), + ).pipe( + // kilocode_change end + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": key, + "tool.call_id": opts.toolCallId, + "session.id": ctx.sessionID, + "message.id": input.processor.message.id, + }, + }), + ) + yield* plugin.trigger( + "tool.execute.after", + { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + result, + ) + + const textParts: string[] = [] + const attachments: Omit[] = [] + for (const contentItem of result.content) { + if (contentItem.type === "text") textParts.push(contentItem.text) + else if (contentItem.type === "image") { + attachments.push({ + type: "file", + mime: contentItem.mimeType, + url: `data:${contentItem.mimeType};base64,${contentItem.data}`, + }) + } else if (contentItem.type === "resource") { + const { resource } = contentItem + if (resource.text) textParts.push(resource.text) + if (resource.blob) { + const mime = resource.mimeType ?? "application/octet-stream" + const size = base64Size(resource.blob) + if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) { + textParts.push( + `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`, + ) + continue + } + if (size > MAX_MCP_RESOURCE_BLOB_BYTES) { + textParts.push( + `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`, + ) + continue + } attachments.push({ type: "file", - mime: contentItem.mimeType, - url: `data:${contentItem.mimeType};base64,${contentItem.data}`, + mime, + url: `data:${mime};base64,${resource.blob}`, + filename: resource.uri, }) - } else if (contentItem.type === "resource") { - const { resource } = contentItem - if (resource.text) textParts.push(resource.text) - if (resource.blob) { - attachments.push({ - type: "file", - mime: resource.mimeType ?? "application/octet-stream", - url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, - filename: resource.uri, - }) - } } } + } - const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent) - const metadata = { - ...result.metadata, - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - } + const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent) + const metadata = { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + } - const output = { - title: "", - metadata, - output: truncated.content, - attachments: attachments.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), - content: result.content, - } - if (opts.abortSignal?.aborted) { - yield* input.processor.completeToolCall(opts.toolCallId, output) - } - return output - }), - ) - tools[key] = item - } + const output = { + title: "", + metadata, + output: truncated.content, + attachments: attachments.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), + content: result.content, + } + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + }), + ) + tools[key] = item +} return tools }) +function toRecord(value: unknown) { + if (isRecord(value)) return value + return {} +} + +function parseListMcpResourcesArgs(value: unknown) { + const args = toRecord(value) + return { server: optionalString(args, "server") } +} + +function parseReadMcpResourceArgs(value: unknown) { + const args = toRecord(value) + return { server: requiredString(args, "server"), uri: requiredString(args, "uri") } +} + +function optionalString(args: Record, key: string) { + const value = args[key] + if (value === undefined || value === null || value === "") return undefined + if (typeof value !== "string") throw new Error(`${key} must be a string`) + return value +} + +function requiredString(args: Record, key: string) { + const value = optionalString(args, key) + if (value) return value + throw new Error(`${key} is required`) +} + +function formatMcpResource(resource: MCP.Resource) { + const result = Object.fromEntries(Object.entries(resource).filter((entry) => entry[0] !== "client")) + return { ...result, server: resource.client } +} + +function formatMcpResourceTemplate(template: Record & { client: string }) { + const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client")) + return { ...result, server: template.client } +} + +function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) { + const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord) + const text: string[] = [] + const attachments: Omit[] = [] + + for (const item of items) { + const itemUri = typeof item.uri === "string" ? item.uri : uri + const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream" + if (typeof item.text === "string") { + text.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`) + continue + } + if (typeof item.blob === "string") { + const size = base64Size(item.blob) + if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) { + text.push( + `[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`, + ) + continue + } + if (size > MAX_MCP_RESOURCE_BLOB_BYTES) { + text.push( + `[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`, + ) + continue + } + text.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`) + attachments.push({ + type: "file", + mime, + url: `data:${mime};base64,${item.blob}`, + filename: itemUri, + }) + continue + } + text.push(`[MCP resource content without text or blob: ${itemUri}]`) + } + + return { + contents: items.length, + attachments, + text: text.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`, + } +} + +function base64Size(value: string) { + const trimmed = value.replace(/\s/g, "") + const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0 + return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding) +} + +function formatBytes(value: number) { + if (value < 1024) return `${value} B` + if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB` + return `${Math.ceil(value / (1024 * 1024))} MB` +} + export * as SessionTools from "./tools" diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 322e2b528df..cc7c534715a 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -14,7 +14,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionShare") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const cfg = yield* Config.Service @@ -48,12 +48,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - -export const node = LayerNode.make(layer, [Config.node, Session.node, RuntimeFlags.node]) // kilocode_change +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, Session.node, RuntimeFlags.node], // kilocode_change - Kilo public sharing uses KiloSession +}) export * as SessionShare from "./session" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 841c04c01be..1246fd2568d 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import type * as SDK from "@kilocode/sdk/v2" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" @@ -124,7 +124,7 @@ function key(item: Data) { } } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const account = yield* Account.Service @@ -377,24 +377,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Account.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), -) - -export const node = LayerNode.make(layer, [ - Account.node, - EventV2Bridge.node, - Config.node, - Database.node, - httpClient, - Provider.node, - Session.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Account.node, EventV2Bridge.node, Config.node, Database.node, httpClient, Provider.node, Session.node], +}) export * as ShareNext from "./share-next" diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index a44127273b9..27467e4143e 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient, path } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient, path } from "@opencode-ai/core/effect/app-node-platform" import { NodePath } from "@effect/platform-node" import { Effect, Layer, Path, Schema, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -14,6 +14,7 @@ const fileConcurrency = 8 class IndexSkill extends Schema.Class("IndexSkill")({ name: Schema.String, files: Schema.Array(Schema.String), + version: Schema.optional(Schema.String), }) {} class Index extends Schema.Class("Index")({ @@ -26,7 +27,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SkillDiscovery") {} -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -78,19 +79,23 @@ export const layer: Layer.Layer Effect.gen(function* () { - yield* Effect.forEach(skill.files, (file) => download(file.url, file.dest), { - concurrency: fileConcurrency, - }) - const md = path.join(skill.root, "SKILL.md") - return (yield* fs.exists(md).pipe(Effect.orDie)) ? skill.root : null + const { root, version } = skill + const versionFile = path.join(root, ".opencode-version") + const fetchInto = (target: string) => + Effect.forEach(skill.files, (file) => download(file.url, path.join(target, file.rel)), { + concurrency: fileConcurrency, + }) + const current = + version === undefined + ? undefined + : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + + if (version === undefined || current === version) { + yield* fetchInto(root) + } else { + const token = crypto.randomUUID() + const staging = `${root}.tmp-${token}` + const backup = `${root}.old-${token}` + yield* Effect.gen(function* () { + const downloaded = yield* fetchInto(staging) + if (!downloaded.every(Boolean)) return + if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return + yield* fs.writeFileString(path.join(staging, ".opencode-version"), version) + yield* Effect.uninterruptible( + Effect.gen(function* () { + const cached = yield* fs.exists(root).pipe(Effect.orDie) + if (cached) yield* fs.rename(root, backup) + yield* fs.rename(staging, root).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore) + return yield* Effect.fail(error) + }), + ), + ) + if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore) + }), + ) + }).pipe( + Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })), + Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), + ) + } + return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null }), { concurrency: skillConcurrency }, ) @@ -120,12 +163,6 @@ export const layer: Layer.Layer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), -) - -export const node = LayerNode.make(layer, [FSUtil.node, path, httpClient]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, path, httpClient] }) export * as Discovery from "./discovery" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index b74dfffbd97..b6e3e4fc94a 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,6 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "path" -import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" @@ -20,6 +19,7 @@ import { primaryPaths } from "../kilocode/primary-worktree" // kilocode_change import { Git } from "@/git" // kilocode_change import { isRecord } from "@/util/record" import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change +import { escapeHtml } from "@/util/html" import { trustedInProject } from "../kilocode/skill/trust" // kilocode_change const CLAUDE_EXTERNAL_DIR = ".claude" @@ -317,7 +317,7 @@ const loadSkills = Effect.fnUntraced(function* ( export class Service extends Context.Service()("@opencode/Skill") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const discovery = yield* Discovery.Service @@ -381,18 +381,6 @@ export const layer = Layer.effect( }), ) -// kilocode_change start - preserve the concrete layer type across Kilo's Agent/Skill cycle -export const defaultLayer: Layer.Layer = layer.pipe( - // kilocode_change end - Layer.provide(Git.defaultLayer), // kilocode_change - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export function fmt(list: Info[], opts: { verbose: boolean }) { const described = list.filter((skill) => skill.description !== undefined) if (described.length === 0) return "No skills are currently available." @@ -405,7 +393,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { " ", ` ${skill.name}`, ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, + ` ${escapeHtml(skill.location)}`, " ", ]), "", @@ -420,14 +408,10 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { ].join("\n") } -export const node = LayerNode.make(layer, [ - Discovery.node, - Config.node, - EventV2Bridge.node, - FSUtil.node, - Global.node, - RuntimeFlags.node, - Git.node, // kilocode_change -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Discovery.node, Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node, Git.node], // kilocode_change +}) export * as Skill from "." diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a1d74809783..37581048dd9 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect" import { Struct } from "effect" // kilocode_change import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -11,6 +12,7 @@ import { Hash } from "@opencode-ai/core/util/hash" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" // kilocode_change import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" +import { Info } from "@opencode-ai/schema/file-diff" // kilocode_change start import { Flag } from "@opencode-ai/core/flag/flag" import { DiffFull } from "../kilocode/snapshot/diff-full" @@ -28,20 +30,8 @@ export const Patch = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) // kilocode_change export type Patch = typeof Patch.Type -export const FileDiff = Schema.Struct({ - // Optional because legacy/imported `summary_diffs` on disk may omit - // file details and patch text. Required Schema rejected the whole - // session response and broke session loading on Desktop. - file: Schema.optional(Schema.String), - patch: Schema.optional(Schema.String), - additions: Schema.Finite, - deletions: Schema.Finite, - status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), - // kilocode_change start -}) - .annotate({ identifier: "SnapshotFileDiff" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) -// kilocode_change end +// kilocode_change - retain the legacy Zod facade while sharing the canonical schema +export const FileDiff = Info.pipe(withStatics((s) => ({ zod: zod(s) }))) export type FileDiff = typeof FileDiff.Type // kilocode_change start - lightweight FileDiff without patch for session summaries @@ -120,6 +110,7 @@ export const layer: Layer.Layer = const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] const feed = (list: string[]) => list.join("\0") + "\0" + const literal = (list: string[]) => feed(list.map((file) => `:(top,literal)${file}`)) const git = Effect.fnUntraced( function* (cmd: string[], opts?: { cwd?: string; env?: Record; stdin?: string }) { @@ -144,6 +135,8 @@ export const layer: Layer.Layer = const ignore = Effect.fnUntraced(function* (files: string[]) { if (!files.length) return new Set() + // check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix. + const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item)) const check = yield* git( [ ...quote, @@ -157,12 +150,18 @@ export const layer: Layer.Layer = "-z", ], { - cwd: state.directory, - stdin: feed(files), + // ls-files --full-name emits worktree-relative candidates, so resolve them from the worktree root + cwd: state.worktree, + stdin: feed(checkIgnorePaths), }, ) if (check.code !== 0 && check.code !== 1) return new Set() - return new Set(check.text.split("\0").filter(Boolean)) + return new Set( + check.text + .split("\0") + .filter(Boolean) + .map((item) => (item.startsWith("./:") ? item.slice(2) : item)), + ) }) const drop = Effect.fnUntraced(function* (files: string[]) { @@ -173,8 +172,9 @@ export const layer: Layer.Layer = ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), ], { - cwd: state.directory, - stdin: feed(files), + // :(top,literal) pathspecs and --full-name candidates are both worktree-relative + cwd: state.worktree, + stdin: literal(files), }, ) }) @@ -196,7 +196,7 @@ export const layer: Layer.Layer = const result = yield* git([...cfg, ...args(cmd)], { cwd: state.directory, env: opts?.env, - stdin: opts?.root ? undefined : feed(files), + stdin: opts?.root ? undefined : literal(files), }) // kilocode_change end if (result.code === 0) return @@ -209,8 +209,7 @@ export const layer: Layer.Layer = const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) // kilocode_change start - restoration must fail if deletion fails - const remove = (file: string) => - fs.remove(file, { force: true }).pipe(Effect.orDie) + const remove = (file: string) => fs.remove(file, { force: true }).pipe(Effect.orDie) // kilocode_change end // kilocode_change start - serialize snapshot repositories across CLI and extension processes const locked = (fx: Effect.Effect) => @@ -256,9 +255,12 @@ export const layer: Layer.Layer = git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], { cwd: state.directory, }), - git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], { - cwd: state.directory, - }), + git( + [...quote, ...args(["ls-files", "--full-name", "--others", "--exclude-standard", "-z", "--", "."])], + { + cwd: state.directory, + }, + ), ], { concurrency: 2 }, ) @@ -295,7 +297,7 @@ export const layer: Layer.Layer = (yield* Effect.all( allow.map((item) => fs - .stat(path.join(state.directory, item)) + .stat(path.join(state.worktree, item)) .pipe(Effect.catch(() => Effect.void)) .pipe( Effect.map((stat) => { @@ -972,13 +974,12 @@ export const layer: Layer.Layer = }), ) -export const defaultLayer = layer.pipe( - Layer.provide(AppProcess.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EffectFlock.defaultLayer), // kilocode_change -) +export const defaultLayer: Layer.Layer = Layer.suspend(() => AppNodeBuilder.build(node)) // kilocode_change - build from the LayerNode graph -export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node, Config.node, EffectFlock.node]) // kilocode_change +export const node = LayerNode.make({ + service: Service, + layer, + deps: [FSUtil.node, AppProcess.node, Config.node, EffectFlock.node], // kilocode_change +}) export * as Snapshot from "." diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index e2aa8e5122f..e101b54c1a3 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -15,6 +15,7 @@ import { InstallationChannel } from "@opencode-ai/core/installation/version" import { EffectBridge } from "@/effect/bridge" import { init } from "#db" import { Effect, Schema } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change declare const KILO_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined @@ -27,7 +28,7 @@ const log = Log.create({ service: "db" }) type DatabaseFlags = Pick const readRuntimeFlags = () => - Effect.runSync(RuntimeFlags.Service.useSync((flags) => flags).pipe(Effect.provide(RuntimeFlags.defaultLayer))) + Effect.runSync(RuntimeFlags.Service.useSync((flags) => flags).pipe(Effect.provide(AppNodeBuilder.build(RuntimeFlags.node)))) export function getChannelPath(flags: Pick = readRuntimeFlags()) { if (["latest", "beta", "prod"].includes(InstallationChannel) || flags.disableChannelDb) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 5449865fb96..ddbd83d7931 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -210,7 +210,7 @@ const MIGRATIONS: Migration[] = [ }), ] -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -322,8 +322,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer)) - -export const node = LayerNode.make(layer, [FSUtil.node, Git.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, Git.node] }) export * as Storage from "./storage" diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index fae697decfa..d6d77cb1061 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -12,11 +12,13 @@ import { EventID } from "./schema" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" import type { DeepMutable } from "@opencode-ai/core/schema" import { EventV2 } from "@opencode-ai/core/event" +import { EventManifest } from "@/event-manifest" // kilocode_change import { serviceUse } from "@opencode-ai/core/effect/service-use" import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { EffectBridge } from "@/effect/bridge" import * as EventWire from "@/kilocode/event-wire" // kilocode_change +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change // Keep `Event["data"]` mutable because projectors mutate the persisted shape // when writing to the database. Bus payloads (`Properties`) stay readonly — @@ -213,7 +215,9 @@ export const layer = Layer.effect(Service)( }), ) -export const defaultLayer = layer.pipe(Layer.provide([ProjectBus.defaultLayer, RuntimeFlags.defaultLayer])) +export const defaultLayer = layer.pipe( + Layer.provide([ProjectBus.defaultLayer, AppNodeBuilder.build(RuntimeFlags.node)]), // kilocode_change +) export const use = serviceUse(Service) @@ -231,12 +235,12 @@ export function reset() { export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) { projectors = new Map(input.projectors.map(([def, func]) => [versionedType(def.type, def.version), func])) - for (let entry of EventV2.registry.values()) { - if (!entry.sync) continue // kilocode_change - EventV2 stores legacy sync metadata under sync + for (const entry of EventManifest.Latest.values()) { + if (!entry.durable) continue // kilocode_change - mirror current durable events into legacy sync register({ type: entry.type, - version: entry.sync.version, // kilocode_change - aggregate: entry.sync.aggregate, // kilocode_change + version: entry.durable.version, // kilocode_change + aggregate: entry.durable.aggregate, // kilocode_change properties: entry.data, schema: entry.data, wire: true, // kilocode_change @@ -417,20 +421,19 @@ export function effectPayloads() { }).annotate({ identifier: `SyncEvent.${type}` }), ) .toArray(), - ...EventV2.registry - .values() + ...EventManifest.Latest.values() .filter( (definition) => - definition.sync !== undefined && // kilocode_change - !registry.has(versionedType(definition.type, definition.sync.version)), // kilocode_change + definition.durable !== undefined && // kilocode_change + !registry.has(versionedType(definition.type, definition.durable.version)), // kilocode_change ) .map((definition) => EffectSchema.Struct({ type: EffectSchema.Literal("sync"), - name: EffectSchema.Literal(versionedType(definition.type, definition.sync!.version)), // kilocode_change + name: EffectSchema.Literal(versionedType(definition.type, definition.durable!.version)), // kilocode_change id: EffectSchema.String, seq: EffectSchema.Finite, - aggregateID: EffectSchema.Literal(definition.sync!.aggregate), // kilocode_change + aggregateID: EffectSchema.Literal(definition.durable!.aggregate), // kilocode_change data: definition.data, }).annotate({ identifier: `SyncEvent.${definition.type}` }), ) diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index 4ff9d2fc335..39b9b92b456 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,11 +1,11 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { withStatics } from "@opencode-ai/core/schema" +import { statics } from "@opencode-ai/core/schema" export const EventID = Schema.String.check(Schema.isStartsWith("evt")).pipe( Schema.brand("EventID"), - withStatics((s) => ({ + statics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), })), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 626f88b25c0..5ab4dc1a7a1 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change import { Ripgrep } from "@opencode-ai/core/ripgrep" import { PlanExitTool } from "./plan" import { Session } from "@/session/session" @@ -43,6 +44,8 @@ import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context, Option } from "effect" // kilocode_change +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { HttpClient } from "effect/unstable/http" // kilocode_change import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Format } from "../format" import { InstanceState } from "@/effect/instance-state" @@ -103,7 +106,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ToolRegistry") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -394,56 +397,7 @@ export const layer = Layer.effect( }), ) -// kilocode_change start - keep Kilo registry requirements type-checked -export const defaultLayer: Layer.Layer = Layer.suspend( - // kilocode_change end - () => - layer - .pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Git.defaultLayer), // kilocode_change - Layer.provide(LSP.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(ToolNetwork.httpLayer), // kilocode_change - Layer.provide(Format.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - // kilocode_change start - Layer.provide( - Ripgrep.layer.pipe( - Layer.provide(RipgrepBinary.layer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(ToolNetwork.httpLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - ), - ), - // kilocode_change end - ) - // kilocode_change start - provide Kilo-owned registry dependencies - .pipe( - Layer.provide(Command.defaultLayer), - Layer.provide(AgentManager.defaultLayer), - Layer.provide(Notebook.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(Truncate.defaultLayer), // kilocode_change - split the pipe to stay within Effect's overload limit - ) - .pipe(Layer.provide(Auth.defaultLayer)) - .pipe(Layer.provide(KiloSessions.defaultLayer)), // kilocode_change - satisfy the notify_user tool's KiloSessions dependency in the tool-registry graph -) +export const defaultLayer: Layer.Layer = Layer.suspend(() => AppNodeBuilder.build(node)) // kilocode_change - build from the LayerNode graph function isZodType(value: unknown): value is z.ZodType { return typeof value === "object" && value !== null && "_zod" in value @@ -522,41 +476,44 @@ function isJsonSchemaObject(value: unknown): value is Record { } // kilocode_change start - preserve Kilo registry dependencies and sandbox-aware HTTP in the upstream node graph -const networkNode = LayerNode.make(ToolNetwork.httpLayer, []) -const busNode = LayerNode.make(Bus.layer, []) -const notebookNode = LayerNode.make(Notebook.defaultLayer, []) -const repositoryCacheNode = LayerNode.make(RepositoryCache.defaultLayer, []) +const network = LayerNode.make({ service: HttpClient.HttpClient, layer: ToolNetwork.httpLayer, deps: [] }) export const node = LayerNode.suspend(() => - LayerNode.make(layer.pipe(Layer.provide(Ripgrep.defaultLayer)), [ - Config.node, - Plugin.node, - Question.node, - Todo.node, - Agent.node, - Skill.node, - Session.node, - BackgroundJob.node, - Provider.node, - LSP.node, - Instruction.node, - FSUtil.node, - EventV2Bridge.node, - networkNode, - CrossSpawnSpawner.node, - Format.node, - Truncate.node, - RuntimeFlags.node, - Database.node, - Command.node, - Git.node, - busNode, - Auth.node, - SessionStatus.node, - notebookNode, - repositoryCacheNode, - KiloSessions.node, // kilocode_change - satisfy the notify_user tool's KiloSessions dependency in the runtime node graph - ]), + LayerNode.make({ + service: Service, + layer, + deps: [ + Config.node, + Plugin.node, + Question.node, + Todo.node, + Agent.node, + Skill.node, + Session.node, + BackgroundJob.node, + Provider.node, + LSP.node, + Instruction.node, + FSUtil.node, + EventV2Bridge.node, + network, + CrossSpawnSpawner.node, + Format.node, + Truncate.node, + RuntimeFlags.node, + Database.node, + Ripgrep.node, + Command.node, + Git.node, + Bus.node, + Auth.node, + SessionStatus.node, + AgentManager.node, + Notebook.node, + RepositoryCache.node, + KiloSessions.node, + ], + }), ) // kilocode_change end diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index cdeadb74d62..c83444866ec 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,14 +1,14 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { withStatics } from "@opencode-ai/core/schema" +import { statics } from "@opencode-ai/core/schema" const toolIdSchema = Schema.String.check(Schema.isStartsWith("tool")).pipe(Schema.brand("ToolID")) export type ToolID = typeof toolIdSchema.Type export const ToolID = toolIdSchema.pipe( - withStatics((schema: typeof toolIdSchema) => ({ + statics((schema: typeof toolIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)), })), ) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index b011f4b3c41..31ab3e36fd5 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -436,7 +436,11 @@ export const ShellPermission = Effect.gen(function* () { // kilocode_change start - expose the tree-sitter scan (sub-command patterns + external-dir globs) for skill-shell batching const dirGlob = (dir: string) => process.platform === "win32" ? FSUtil.normalizePathPattern(path.join(dir, "*")) : path.join(dir, "*") - const decompose = Effect.fn("ShellTool.decompose")(function* (input: { command: string; cwd: string; shell: string }) { + const decompose = Effect.fn("ShellTool.decompose")(function* (input: { + command: string + cwd: string + shell: string + }) { const instance = yield* InstanceState.context const ps = Shell.ps(input.shell) return yield* Effect.scoped( @@ -531,7 +535,7 @@ export const ShellTool = Tool.define( cwd: string env: NodeJS.ProcessEnv timeout: number - description: string + description: string // kilocode_change }, ctx: Tool.Context, ) { @@ -575,7 +579,6 @@ export const ShellTool = Tool.define( yield* ctx.metadata({ metadata: { output: "", - description: input.description, }, }) @@ -584,7 +587,8 @@ export const ShellTool = Tool.define( yield* Effect.addFinalizer(closeSink) const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env)) - const reader = yield* Effect.forkScoped( // kilocode_change - keep the fiber so trailing output can be drained + const reader = yield* Effect.forkScoped( + // kilocode_change - keep the fiber so trailing output can be drained Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { const size = Buffer.byteLength(chunk, "utf-8") list.push({ text: chunk, size }) @@ -616,7 +620,6 @@ export const ShellTool = Tool.define( ctx.metadata({ metadata: { output: last, - description: input.description, }, }), ), @@ -627,7 +630,6 @@ export const ShellTool = Tool.define( return ctx.metadata({ metadata: { output: last, - description: input.description, }, }) }), @@ -695,11 +697,11 @@ export const ShellTool = Tool.define( output += "\n\n\n" + meta.join("\n") + "\n" } return { - title: input.description, + title: input.description, // kilocode_change - UI shows the model's description, command goes in metadata metadata: { output: last || preview(output), exit: code, - description: input.description, + description: input.description, // kilocode_change truncated: cut, ...(cut && file ? { outputPath: file } : {}), }, diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 84d719a2504..e8c04833138 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -15,7 +15,6 @@ const descriptions = { cmd: 'Recommended: a clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', } // kilocode_change end - export type Limits = { maxLines: number maxBytes: number diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index faa949c0c9e..6a5b54c6a64 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,5 +1,4 @@ import path from "path" -import { pathToFileURL } from "url" import { Effect, Schema } from "effect" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Skill } from "../skill" @@ -77,7 +76,7 @@ export const SkillTool = Tool.define( // kilocode_change end const dir = path.dirname(info.location) - const base = pathToFileURL(dir).href + const base = dir const files = yield* ripgrep.find({ cwd: dir, pattern: "!**/SKILL.md", diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 30ffcfe6d79..b9043275ffc 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -6,19 +6,8 @@ import { Todo } from "../session/todo" import { TodoView } from "../kilocode/todo-view" // kilocode_change end -// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape -// here rather than referencing its `.shape` — the LLM-visible JSON Schema is -// identical, and it removes the last zod dependency from this tool. -const TodoItem = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), -}) - export const Parameters = Schema.Struct({ - todos: Schema.mutable(Schema.Array(TodoItem)).annotate({ description: "The updated todo list" }), + todos: Schema.mutable(Schema.Array(Todo.Info)).annotate({ description: "The updated todo list" }), }) type Metadata = { diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 1815643d03b..3a48c90a98e 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -46,7 +46,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Truncate") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -151,8 +151,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer)) - -export const node = LayerNode.make(layer, [FSUtil.node]) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] }) export * as Truncate from "./truncate" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 029d4eb6324..f8f8ab7324d 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,7 +1,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { path } from "@opencode-ai/core/effect/layer-node-platform" +import { path } from "@opencode-ai/core/effect/app-node-platform" import { Global } from "@opencode-ai/core/global" -import { InstanceLayer } from "@/project/instance-layer" import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Database } from "@opencode-ai/core/database/database" @@ -10,32 +9,17 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import type { ProjectV2 } from "@opencode-ai/core/project" import { Slug } from "@opencode-ai/core/util/slug" import { errorMessage } from "../util/error" -import { EventV2 } from "@opencode-ai/core/event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" import { Effect, Layer, Path, Schema, Scope, Context } from "effect" import { ChildProcess } from "effect/unstable/process" -import { NodePath } from "@effect/platform-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" import { InstanceState } from "@/effect/instance-state" import { WorktreeCleanup } from "@/kilocode/worktree-cleanup" // kilocode_change +import { WorktreeEvent } from "@opencode-ai/schema/worktree-event" -export const Event = { - Ready: EventV2.define({ - type: "worktree.ready", - schema: { - name: Schema.String, - branch: Schema.optional(Schema.String), - }, - }), - Failed: EventV2.define({ - type: "worktree.failed", - schema: { - message: Schema.String, - }, - }), -} +export const Event = WorktreeEvent export const Info = Schema.Struct({ name: Schema.String, @@ -146,7 +130,7 @@ export class Service extends Context.Service()("@opencode/Wo type GitResult = { code: number; text: string; stderr: string } -export const layer: Layer.Layer< +const layer: Layer.Layer< Service, never, | FSUtil.Service @@ -625,25 +609,10 @@ export const layer: Layer.Layer< }), ) -export const appLayer = layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), -) - -export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer)) - -export const node = LayerNode.make(layer, [ - FSUtil.node, - path, - AppProcess.node, - Git.node, - Project.node, - InstanceStore.node, - Database.node, -]) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, path, AppProcess.node, Git.node, Project.node, InstanceStore.node, Database.node], +}) export * as Worktree from "." diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index 42851fc19d4..756d873f9c7 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer, Option } from "effect" import { sql } from "drizzle-orm" @@ -13,9 +14,10 @@ const truncate = Layer.effectDiscard( yield* db.run(sql`DELETE FROM account_state`) yield* db.run(sql`DELETE FROM account`) }), -).pipe(Layer.provide(Database.defaultLayer)) +) +const truncateNode = LayerNode.make({ name: "truncate-account", layer: truncate, deps: [Database.node] }) -const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) +const it = testEffect(LayerNode.compile(LayerNode.group([AccountRepo.node, truncateNode]))) it.live("list returns empty when no accounts exist", () => Effect.gen(function* () { diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index e3c57315003..eb246cfe824 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -1,4 +1,6 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Duration, Effect, Layer, Option, Schema } from "effect" import { sql } from "drizzle-orm" import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http" @@ -25,15 +27,16 @@ const truncate = Layer.effectDiscard( yield* db.run(sql`DELETE FROM account_state`) yield* db.run(sql`DELETE FROM account`) }), -).pipe(Layer.provide(Database.defaultLayer)) +) +const truncateNode = LayerNode.make({ name: "truncate-account", layer: truncate, deps: [Database.node] }) -const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) +const it = testEffect(LayerNode.compile(LayerNode.group([AccountRepo.node, truncateNode]))) const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1)) const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10)) const live = (client: HttpClient.HttpClient) => - Layer.fresh(Account.layer).pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) // kilocode_change + LayerNode.compile(Account.node, [[httpClient, Layer.succeed(HttpClient.HttpClient, client)]]) // kilocode_change const json = (req: Parameters[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( diff --git a/packages/opencode/test/acp/content.test.ts b/packages/opencode/test/acp/content.test.ts index 90f62f9d189..ab5ffdb94a8 100644 --- a/packages/opencode/test/acp/content.test.ts +++ b/packages/opencode/test/acp/content.test.ts @@ -99,17 +99,51 @@ describe("acp content conversion", () => { ]) }) - test("resource with text becomes a text part", () => { + test("resource with text becomes a sourced text part", () => { + const result = contentBlockToParts({ + type: "resource", + resource: { + uri: "file:///tmp/context.txt#L12-L14", + mimeType: "text/plain", + text: "context", + }, + }) + expect(result).toHaveLength(1) + expect(result[0]?.type).toBe("text") + if (result[0]?.type === "text") { + expect(result[0].text.endsWith("\ncontext")).toBe(true) + expect(result[0].text.includes("context.txt")).toBe(true) + expect(result[0].text.includes("12")).toBe(true) + } + }) + + test("resource with text uses URI fallback for non-file resources", () => { expect( contentBlockToParts({ type: "resource", resource: { - uri: "file:///tmp/context.txt", - mimeType: "text/plain", + uri: "mcp://server/context", text: "context", }, }), - ).toEqual([{ type: "text", text: "context" }]) + ).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }]) + }) + + test("resource with text includes file path", () => { + const result = contentBlockToParts({ + type: "resource", + resource: { + uri: "file:///tmp/context.txt", + mimeType: "text/plain", + text: "context", + }, + }) + expect(result).toHaveLength(1) + expect(result[0]?.type).toBe("text") + if (result[0]?.type === "text") { + expect(result[0].text.endsWith("\ncontext")).toBe(true) + expect(result[0].text.includes("context.txt")).toBe(true) + } }) test("resource with blob and mimeType becomes a data URL file part", () => { diff --git a/packages/opencode/test/acp/directory.test.ts b/packages/opencode/test/acp/directory.test.ts index e274db85cb3..75f2d508545 100644 --- a/packages/opencode/test/acp/directory.test.ts +++ b/packages/opencode/test/acp/directory.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Directory } from "@/acp/directory" import { Command } from "@/command" +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" @@ -83,8 +84,9 @@ const snapshot = (directory: string) => { } const fakeLayer = (calls: string[]) => - Directory.layer.pipe( - Layer.provide( + LayerNode.compile(Directory.node, [ + [ + Directory.loaderNode, Layer.succeed( Directory.Loader, Directory.Loader.of({ @@ -95,8 +97,8 @@ const fakeLayer = (calls: string[]) => }), }), ), - ), - ) + ], + ]) describe("ACP directory snapshot", () => { it.effect("two concurrent callers share one load", () => { diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 7067e7a3003..e860b0c57f6 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import type { Event, Message, KiloClient, Part, SessionMessageResponse, ToolPart } from "@kilocode/sdk/v2" import { Effect, ManagedRuntime } from "effect" import { ACPEvent } from "@/acp/event" @@ -30,7 +31,7 @@ const pollUntil = async ( } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts index f789086d374..1ad85080511 100644 --- a/packages/opencode/test/acp/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test" +import { afterEach, describe, expect, it } from "bun:test" import type { AgentSideConnection, RequestPermissionRequest, @@ -6,13 +6,23 @@ import type { SessionUpdate, } from "@agentclientprotocol/sdk" import type { Event, KiloClient } from "@kilocode/sdk/v2" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { createTwoFilesPatch } from "diff" import { Effect, ManagedRuntime } from "effect" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" import { ACPEvent } from "@/acp/event" import { ACPSession } from "@/acp/session" type PermissionEvent = Extract type PermissionReplyParams = Parameters[0] type SessionUpdateParams = Parameters[0] +const cleanupDirs: string[] = [] + +afterEach(async () => { + await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) const pollUntil = async ( check: () => boolean | Promise, @@ -28,7 +38,7 @@ const pollUntil = async ( } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } @@ -137,6 +147,14 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) { .join("") } +async function tempFile(name: string, content: string) { + const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-")) + cleanupDirs.push(dir) + const file = path.join(dir, name) + await Bun.write(file, content) + return file +} + describe("acp permissions", () => { it("sends requestPermission and replies with the selected outcome", async () => { const harness = createHarness() @@ -151,7 +169,7 @@ describe("acp permissions", () => { toolCall: { toolCallId: "call_1", status: "pending", - title: "bash", + title: "printf hello", rawInput: { command: "printf hello" }, kind: "execute", locations: [], @@ -169,6 +187,116 @@ describe("acp permissions", () => { // kilocode_change end }) + it("uses permission metadata for non-shell titles", async () => { + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_fetch", { + permission: "webfetch", + metadata: { + url: "https://example.com/docs", + format: "markdown", + }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied") + + expect(harness.requests[0]?.toolCall).toMatchObject({ + toolCallId: "call_1", + title: "https://example.com/docs", + kind: "fetch", + rawInput: { url: "https://example.com/docs", format: "markdown" }, + }) + }) + + it("includes a diff content block for edit permission metadata", async () => { + const filepath = await tempFile("file.ts", "before\n") + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_edit", { + permission: "edit", + metadata: { + filepath, + diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"), + }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "edit permission was never replied") + + expect(harness.requests[0]?.toolCall).toMatchObject({ + toolCallId: "call_1", + title: filepath, + kind: "edit", + locations: [{ path: filepath }], + content: [ + { + type: "diff", + path: filepath, + oldText: "before\n", + newText: "after\n", + }, + ], + }) + }) + + it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => { + const first = await tempFile("first.ts", "one\n") + const second = await tempFile("second.ts", "alpha\n") + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_patch", { + permission: "edit", + metadata: { + filepath: "first.ts, second.ts", + files: [ + { + filePath: first, + relativePath: "first.ts", + patch: createTwoFilesPatch(first, first, "one\n", "two\n"), + }, + { + filePath: second, + relativePath: "second.ts", + patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"), + }, + ], + }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied") + + expect(harness.requests[0]?.toolCall).toMatchObject({ + toolCallId: "call_1", + title: "2 files", + locations: [{ path: first }, { path: second }], + content: [ + { + type: "diff", + path: first, + oldText: "one\n", + newText: "two\n", + }, + { + type: "diff", + path: second, + oldText: "alpha\n", + newText: "beta\n", + }, + ], + }) + }) + it("forwards external_directory metadata and locations to requestPermission", async () => { const harness = createHarness() await createSession(harness.session, "ses_a") @@ -193,7 +321,7 @@ describe("acp permissions", () => { toolCall: { toolCallId: "call_1", status: "pending", - title: "external_directory", + title: "Create external directory", rawInput: { command: "mkdir -p /tmp/outside", description: "Create external directory", diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 0387d4242ec..a9d1d2884e5 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -10,7 +10,7 @@ import type { SessionConfigSelectOption, SetSessionConfigOptionResponse, } from "@agentclientprotocol/sdk" -import type { KiloClient } from "@kilocode/sdk/v2" +import type { AssistantMessage, KiloClient } from "@kilocode/sdk/v2" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Effect } from "effect" @@ -144,7 +144,10 @@ const provider: Provider.Info = { describe("ACP service sessions", () => { const makeService = ( messages: readonly { info: unknown; parts: readonly unknown[] }[] = [], - options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> }, + options?: { + abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> + prompt?: (input: unknown) => Promise<{ data: { info: ReturnType } }> + }, ) => { const updates: SessionNotification[] = [] const mcpAdds: string[] = [] @@ -193,19 +196,21 @@ describe("ACP service sessions", () => { data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions, }), messages: () => Promise.resolve({ data: messages }), - prompt: (input: unknown) => { - prompts.push(input) - return Promise.resolve({ - data: { - info: assistantInfo({ - input: 100, - output: 40, - reasoning: 7, - cache: { read: 11, write: 13 }, - }), - }, - }) - }, + prompt: + options?.prompt ?? + ((input: unknown) => { + prompts.push(input) + return Promise.resolve({ + data: { + info: assistantInfo({ + input: 100, + output: 40, + reasoning: 7, + cache: { read: 11, write: 13 }, + }), + }, + }) + }), command: (input: unknown) => { commands.push(input) return Promise.resolve({ @@ -389,15 +394,29 @@ describe("ACP service sessions", () => { expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"]) }) - it("resumes a session and stores restored state", async () => { - const { service } = makeService([ + it("resumes a session and stores restored state without replaying transcript chunks", async () => { + const { service, updates } = makeService([ { info: { + id: "msg_user", + sessionID: "ses_resume", role: "user", model: { providerID: "test", modelID: "test-model", variant: "high" }, agent: "plan", }, - parts: [], + parts: [{ id: "part_user", sessionID: "ses_resume", messageID: "msg_user", type: "text", text: "hello" }], + }, + { + info: { id: "msg_assistant", sessionID: "ses_resume", role: "assistant" }, + parts: [ + { + id: "part_assistant", + sessionID: "ses_resume", + messageID: "msg_assistant", + type: "text", + text: "hi there", + }, + ], }, ]) const resumed = await Effect.runPromise( @@ -409,6 +428,11 @@ describe("ACP service sessions", () => { expect(select(resumed, "effort")?.currentValue).toBe("high") expect(select(updated, "effort")?.currentValue).toBe("default") + expect( + updates + .map((item) => item.update) + .filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"), + ).toEqual([]) }) it("closes local ACP state and aborts the backing session best-effort", async () => { @@ -994,6 +1018,52 @@ describe("ACP service sessions", () => { expect(usageUpdates).toEqual([session.sessionId]) }) + it("maps assistant prompt errors to request errors instead of end turn", async () => { + const { service } = makeService([], { + prompt: () => + Promise.resolve({ + data: { + info: assistantInfo( + { input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + { name: "APIError", data: { message: "Provider request failed", isRetryable: false } }, + ), + }, + }), + }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + const error = await Effect.runPromise( + service + .prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }) + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), + ) + + expect(error.code).toBe(-32603) + expect(error.message).toBe("Internal error: Provider request failed") + expect(error.data).toEqual({ service: "session", errorName: "APIError" }) + }) + + it("maps aborted assistant prompt errors to cancelled", async () => { + const { service } = makeService([], { + prompt: () => + Promise.resolve({ + data: { + info: assistantInfo( + { input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + { name: "MessageAbortedError", data: { message: "Aborted" } }, + ), + }, + }), + }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + const result = await Effect.runPromise( + service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }), + ) + + expect(result.stopReason).toBe("cancelled") + }) + it("prompt maps assistant and user audience annotations", async () => { const { service, prompts } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -1145,13 +1215,17 @@ describe("ACP service sessions", () => { }) }) -function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage { +function assistantInfo( + tokens: UsageService.AssistantTokenCost["tokens"], + error?: AssistantMessage["error"], +): UsageService.AssistantMessage & Pick { return { role: "assistant", providerID: "test", modelID: "test-model", cost: 0, tokens, + ...(error ? { error } : {}), } } diff --git a/packages/opencode/test/acp/session.test.ts b/packages/opencode/test/acp/session.test.ts index c3d41ef08e4..89968563c9d 100644 --- a/packages/opencode/test/acp/session.test.ts +++ b/packages/opencode/test/acp/session.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import type { McpServer } from "@agentclientprotocol/sdk" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -7,7 +8,7 @@ import * as ACPError from "@/acp/error" import * as ACPSession from "@/acp/session" import { testEffect } from "../lib/effect" -const sessionTest = testEffect(ACPSession.defaultLayer) +const sessionTest = testEffect(LayerNode.compile(ACPSession.node)) const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({ providerID: ProviderV2.ID.make(providerID), diff --git a/packages/opencode/test/acp/tool.test.ts b/packages/opencode/test/acp/tool.test.ts index 1713f6faf09..a243e10cc78 100644 --- a/packages/opencode/test/acp/tool.test.ts +++ b/packages/opencode/test/acp/tool.test.ts @@ -2,9 +2,11 @@ import { resolve } from "path" import { describe, expect, test } from "bun:test" import { completedToolContent, + completedToolUpdate, completedToolRawOutput, extractImageAttachments, imageContents, + pendingToolCall, shellOutputSnapshot, toLocations, toToolKind, @@ -111,6 +113,85 @@ describe("acp tool conversion", () => { ]) }) + test("sends completed tool calls as partial updates", () => { + expect( + pendingToolCall({ + toolCallId: "tool-1", + toolName: "edit", + state: { + input: { + filePath: "/tmp/file.ts", + oldString: "before", + newString: "after", + }, + }, + }), + ).toMatchObject({ + kind: "edit", + locations: [{ path: "/tmp/file.ts" }], + rawInput: { + filePath: "/tmp/file.ts", + oldString: "before", + newString: "after", + }, + }) + + expect( + completedToolUpdate({ + toolCallId: "tool-1", + toolName: "edit", + state: { + status: "completed", + input: { + filePath: "/tmp/file.ts", + oldString: "before", + newString: "after", + }, + output: "Edit applied successfully.", + }, + }), + ).toEqual({ + toolCallId: "tool-1", + status: "completed", + content: [ + { + type: "content", + content: { type: "text", text: "Edit applied successfully." }, + }, + { + type: "diff", + path: "/tmp/file.ts", + oldText: "before", + newText: "after", + }, + ], + rawOutput: { + output: "Edit applied successfully.", + }, + }) + + expect( + completedToolUpdate({ + toolCallId: "tool-1", + toolName: "edit", + state: { + status: "completed", + input: { + filePath: "/tmp/file.ts", + oldString: "before", + newString: "after", + }, + title: "file.ts", + output: "Edit applied successfully.", + }, + }), + ).toMatchObject({ + toolCallId: "tool-1", + status: "completed", + title: "file.ts", + }) + }) + test("uses clean read display text for completed content", () => { const output = [ "/tmp/file.ts", diff --git a/packages/opencode/test/acp/usage.test.ts b/packages/opencode/test/acp/usage.test.ts index d2ff139c563..06ccfb1f506 100644 --- a/packages/opencode/test/acp/usage.test.ts +++ b/packages/opencode/test/acp/usage.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SessionNotification } from "@agentclientprotocol/sdk" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { UsageService } from "@/acp/usage" @@ -97,24 +98,26 @@ const fakeLayer = (input: { readonly messages?: Effect.Effect readonly providers?: (directory: string) => Effect.Effect, unknown> }) => - UsageService.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed( - UsageService.MessageLoader, - UsageService.MessageLoader.of({ - messages: () => input.messages ?? Effect.succeed([]), - }), - ), - Layer.succeed( - UsageService.ContextLimitLoader, - UsageService.ContextLimitLoader.of({ - providers: input.providers ?? (() => Effect.succeed(providers())), - }), - ), + LayerNode.compile(UsageService.node, [ + [ + UsageService.messageLoaderNode, + Layer.succeed( + UsageService.MessageLoader, + UsageService.MessageLoader.of({ + messages: () => input.messages ?? Effect.succeed([]), + }), ), - ), - ) + ], + [ + UsageService.contextLimitLoaderNode, + Layer.succeed( + UsageService.ContextLimitLoader, + UsageService.ContextLimitLoader.of({ + providers: input.providers ?? (() => Effect.succeed(providers())), + }), + ), + ], + ]) const connection = (updates: SessionNotification[]) => ({ sessionUpdate(params: SessionNotification) { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 33bb1d2127f..e46d395c601 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Layer } from "effect" import path from "path" @@ -15,19 +16,19 @@ import { Provider } from "../../src/provider/provider" import { Skill } from "../../src/skill" import { Truncate } from "../../src/tool/truncate" import { MCP } from "../../src/mcp" // kilocode_change -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap as InstanceBootstrapNode } from "../../src/project/bootstrap" const agentLayer = (flags: Partial = {}) => - Agent.layer.pipe( - Layer.provide(Plugin.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Layer.mock(MCP.Service)({})), // kilocode_change - Layer.provide(LocationServiceMap.layer), // kilocode_change - Layer.provide(RuntimeFlags.layer(flags)), - ) + AppNodeBuilder.build(Agent.node, [ + [MCP.node, Layer.mock(MCP.Service)({})], // kilocode_change + [RuntimeFlags.node, RuntimeFlags.layer(flags)], + [ + InstanceBootstrapNode.node, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], + ]) const it = testEffect(agentLayer()) const scout = testEffect(agentLayer({ experimentalScout: true })) // kilocode_change diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts index a88964ecede..ff0ecbbea17 100644 --- a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -1,4 +1,5 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { expect } from "bun:test" import { Effect } from "effect" import { Agent } from "../../src/agent/agent" @@ -7,7 +8,7 @@ import { Permission } from "../../src/permission" import { KiloTask } from "../../src/kilocode/tool/task" // kilocode_change import { testEffect } from "../lib/effect" -const it = testEffect(Agent.defaultLayer) +const it = testEffect(LayerNode.compile(Agent.node)) function testAgent(input: { name: string diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 4d7116a64ed..9f474b4ff68 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,6 +1,7 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import { FSUtil } from "@opencode-ai/core/fs-util" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import path from "path" @@ -20,6 +21,11 @@ import { ProviderTest } from "../fake/provider" import { SkillTest } from "../fake/skill" import { testEffect } from "../lib/effect" import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" +import { Auth } from "../../src/auth" +import { Account } from "../../src/account/account" +import { Npm } from "@opencode-ai/core/npm" +import { Skill } from "../../src/skill" +import { Provider } from "../../src/provider/provider" // `it.instance` skips InstanceBootstrap so LSP / MCP don't spin up — those // services hang during scope teardown on Windows and aren't needed @@ -27,31 +33,26 @@ import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href const provider = ProviderTest.fake() -const configLayer = Config.layer.pipe( - Layer.provide(Git.defaultLayer), // kilocode_change - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), -) -const pluginLayer = Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), -) +const configLayer = AppNodeBuilder.build(Config.node, [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], +]) +const pluginLayer = AppNodeBuilder.build(Plugin.node, [ + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], +]) const dependencies = Layer.mergeAll(configLayer, pluginLayer).pipe(Layer.provideMerge(configLayer)) -const agentLayer = Agent.layer.pipe( - Layer.provide(AuthTest.empty), - Layer.provide(SkillTest.empty), - Layer.provide(Layer.mock(MCP.Service)({})), // kilocode_change - Layer.provide(provider.layer), - Layer.provide(pluginLayer), - Layer.provide(pluginLayer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), -) +const agentLayer = AppNodeBuilder.build(Agent.node, [ + [Auth.node, AuthTest.empty], + [Skill.node, SkillTest.empty], + [MCP.node, Layer.mock(MCP.Service)({})], // kilocode_change + [Provider.node, provider.layer], + [Plugin.node, pluginLayer], + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], +]) const layer = Layer.mergeAll(agentLayer, dependencies).pipe(Layer.provideMerge(dependencies)) const it = testEffect(layer) diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index 58ce6ea718d..bb72be66e57 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -1,12 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { Auth } from "../../src/auth" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" -const node = CrossSpawnSpawner.defaultLayer - -const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node)) +const it = testEffect(LayerNode.compile(Auth.node)) describe("Auth", () => { it.instance("set normalizes trailing slashes in keys", () => diff --git a/packages/opencode/test/background/job.test.ts b/packages/opencode/test/background/job.test.ts index dbcb484dc67..649f935c085 100644 --- a/packages/opencode/test/background/job.test.ts +++ b/packages/opencode/test/background/job.test.ts @@ -1,9 +1,10 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect } from "effect" import { BackgroundJob } from "@/background/job" import { testEffect } from "../lib/effect" -const it = testEffect(BackgroundJob.defaultLayer) +const it = testEffect(LayerNode.compile(BackgroundJob.node)) describe("background.job", () => { it.instance("tracks started jobs through completion", () => diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts index 683bf829966..1ee3c405102 100644 --- a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect } from "effect" import { effectCmd } from "../../src/cli/effect-cmd" @@ -8,7 +9,7 @@ import { Instance } from "../../src/kilocode/instance" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(FSUtil.defaultLayer) +const it = testEffect(LayerNode.compile(FSUtil.node)) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e8bbbb6d421..7a5c5b19cdd 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -52,18 +52,21 @@ Positionals: url http://localhost:4096 [string] [required] Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --dir directory to run in [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] - -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo') [string] + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --dir directory to run in [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] + -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo') [string] + --mini start the minimal interactive interface [boolean] [default: false] + --no-replay disable mini session history replay on resume and after resize [boolean] + --replay-limit cap visible mini replay to the newest N messages [number] " `; @@ -76,50 +79,35 @@ Positionals: message message to send [array] [default: []] Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --command the command to run, use message for args [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session before continuing (requires --continue or - --session) [boolean] - --cloud-fork fetch session from cloud and continue locally (use with - --session) [boolean] - --share share the session [boolean] - -m, --model model to use in the format of provider/model [string] - --agent agent to use [string] - --format format: default (formatted) or json (raw JSON events) + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --command the command to run, use message for args [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session before continuing (requires --continue or --session) [boolean] + --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] + --share share the session [boolean] + -m, --model model to use in the format of provider/model [string] + --agent agent to use [string] + --format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"] - -f, --file file(s) to attach to message [array] - --title title for the session (uses truncated prompt if no value - provided) [string] - --attach attach to a running kilo server (e.g., http://localhost:4096) - [string] - -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD)[string] - -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or - 'kilo') [string] - --dir directory to run in, path on remote server if attaching - [string] - --port port for the local server (defaults to random port if no value - provided) [number] - --variant model variant (provider-specific reasoning effort, e.g., high, - max, minimal) [string] - --thinking show thinking blocks [boolean] - --replay replay interactive session history on resume and after resize - (use --no-replay to disable) [boolean] [default: true] - --replay-limit cap visible interactive replay to the newest N messages + -f, --file file(s) to attach to message [array] + --title title for the session (uses truncated prompt if no value provided) [string] + --attach attach to a running kilo server (e.g., http://localhost:4096) [string] + -p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to KILO_SERVER_USERNAME or 'kilo') [string] + --dir directory to run in, path on remote server if attaching [string] + --port port for the local server (defaults to random port if no value provided) [number] - -i, --interactive run in direct interactive split-footer mode + --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) + [string] + --thinking show thinking blocks [boolean] + -i, --interactive run in direct interactive split-footer mode [boolean] [default: false] + --auto auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false] - --dangerously-skip-permissions auto-approve permissions that are not explicitly denied - (dangerous!) [boolean] [default: false] - --auto auto-approve all permissions (for autonomous/pipeline usage) - [boolean] [default: false] - --demo enable direct interactive demo slash commands; pass one as the - message to run it immediately [boolean] [default: false] " `; diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index 2a759a897b9..2f28861d4ef 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -15,7 +15,6 @@ // version (changes per release), so we'd snapshot a moving target. import { describe, expect } from "bun:test" import { Effect } from "effect" -import { EOL } from "os" import { cliIt } from "../../lib/cli-process" import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot" @@ -116,7 +115,11 @@ describe("Kilo CLI help-text snapshots", () => { Effect.gen(function* () { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) - expect(topLevel.stderr.endsWith(EOL)).toBe(true) + expect(topLevel.stderr.endsWith("\n")).toBe(true) + expect(topLevel.stderr).toContain("--mini") + expect(topLevel.stderr).not.toContain("--thinking") + expect(topLevel.stderr).not.toContain("--variant") + expect(topLevel.stderr).not.toContain("--demo") const argvs: Array = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS] diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index f50ad1c796a..2dce9e8cb21 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -5,56 +5,110 @@ // `KILO_CONFIG_CONTENT` providing the test provider config inline. import { describe, expect } from "bun:test" import { Effect } from "effect" +import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" describe("opencode run (non-interactive subprocess)", () => { - // kilocode_change start - // Keep full CLI subprocesses serial within this file; the test runner already - // executes files in parallel, and nested concurrency exhausts Windows CI. // Happy path: prompt completes, output reaches stdout, process exits 0. // If this fails, all the others likely will too — debug here first. - cliIt.live( + cliIt.concurrent( "exits 0 and writes the response to stdout on a successful prompt", ({ llm, opencode }) => Effect.gen(function* () { yield* llm.text("hello from the test llm") const result = yield* opencode.run("say hi") opencode.expectExit(result, 0) - expect(result.stdout).toContain("hello from the test llm") + expect(result.stdout).toBe("hello from the test llm\n") }), 60_000, ) - // kilocode_change end - // kilocode_change start + cliIt.concurrent( + "prints each completed text part in order around a tool continuation", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().text(" before tool ").tool("bash", { + command: "printf tool-output", + description: "Print deterministic output", + }), + ) + yield* llm.text(" after tool ") + + const result = yield* opencode.run("use a tool", { + extraArgs: ["--dangerously-skip-permissions"], + }) + + opencode.expectExit(result, 0) + expect(result.stdout).toBe("before tool\nafter tool\n") + }), + 60_000, + ) + + cliIt.concurrent( + "prints reasoning before text only with --thinking", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.reason(" considering ", { text: " answer " }) + const thinking = yield* opencode.run("think", { extraArgs: ["--thinking"] }) + opencode.expectExit(thinking, 0) + expect(thinking.stdout).toBe("Thinking: considering\nanswer\n") + + yield* llm.reason("hidden", { text: "visible" }) + const plain = yield* opencode.run("think again") + opencode.expectExit(plain, 0) + expect(plain.stdout).toBe("visible\n") + }), + 60_000, + ) // Regression for #27371: an unknown model used to hang the process forever // waiting on a session.status === idle event that never arrived. The fix - // makes the SDK call surface an error promptly so the process exits 1. - // A harness timeout produces synthetic exit code -1, so the exact assertion - // distinguishes the intended failure from a signal-killed process. - cliIt.live( + // makes the SDK call surface an error promptly so the process exits nonzero. + // We assert nonzero exit AND wall-clock under the harness timeout — a hang + // would expire the timeout and produce a different (signal-killed) failure. + cliIt.concurrent( "exits nonzero promptly when the model is unknown (regression for #27371)", ({ opencode }) => Effect.gen(function* () { const result = yield* opencode.run("say hi", { model: "test/nonexistent-model", - timeoutMs: 30_000, + timeoutMs: 15_000, }) - opencode.expectExit(result, 1) + expect(result.exitCode).not.toBe(0) + expect(result.durationMs).toBeLessThan(15_000) + }), + 30_000, + ) + + // The test provider's SSE error item is interpreted by the SDK as an unknown + // finish, not a fatal provider/session error. Lock that distinction in so it + // is not accidentally used as the failure compatibility oracle. + cliIt.concurrent( + "unknown stream finish preserves partial output and exits 0", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().text("partial response").tool("bash", { + command: "printf tool", + description: "Print deterministic output", + }), + ) + yield* llm.fail("upstream provider exploded mid-stream") + // kilocode_change - settle bash up front so the run exercises the stream finish rather than + // Kilo's auto-reject exit contract, which a plain headless run would trip first. + const result = yield* opencode.run("trigger midstream error", { + timeoutMs: 30_000, + permission: { bash: "deny" }, + }) + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe("partial response\n") + expect(result.stderr).not.toContain("upstream provider exploded mid-stream") }), 60_000, ) - // kilocode_change end - // kilocode_change start - // Was: "mid-stream LLM error still exits 0 today (contract lock-in)" using - // llm.fail(...). That fixture never published a session.error the CLI - // consumed (stream failure recovered as incomplete; idle with error unset), - // so the lock-in was locking in a false premise. Contract change: a run - // whose session actually errored mid-stream must exit non-zero with a - // stderr diagnostic naming the cause — callers (docs-sync) cannot otherwise - // distinguish success from a dead run and had to stop trusting exit codes. - cliIt.live( + // kilocode_change start - Kilo headless runs must signal an unsuccessful session to automation + cliIt.concurrent( "mid-stream session error exits nonzero with a stderr diagnostic", ({ llm, opencode }) => Effect.gen(function* () { @@ -66,7 +120,7 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) - cliIt.live( + cliIt.concurrent( "mid-stream session error exits nonzero with stderr diagnostic under --format json", ({ llm, opencode }) => Effect.gen(function* () { @@ -77,15 +131,12 @@ describe("opencode run (non-interactive subprocess)", () => { }) opencode.expectExit(result, 1) expect(result.stderr).toContain("upstream provider exploded mid-stream") - const events = opencode.parseJsonEvents(result.stdout) - expect(events.some((event) => event.type === "error")).toBe(true) + expect(opencode.parseJsonEvents(result.stdout).some((event) => event.type === "error")).toBe(true) }), 60_000, ) - // Plain headless run (no --auto): CLI auto-rejects bash asks under the - // harness's isolated config. Any auto-reject ⇒ non-zero, even if idle. - cliIt.live( + cliIt.concurrent( "auto-rejected permission in plain headless run exits nonzero", ({ llm, opencode }) => Effect.gen(function* () { @@ -94,45 +145,38 @@ describe("opencode run (non-interactive subprocess)", () => { opencode.expectExit(result, 1) expect(result.stderr).toContain("permission requested: bash") expect(result.stderr).toContain("auto-rejecting") - expect(result.stderr).toContain( - "run ended with an auto-rejected permission; pass --auto for autonomous use", - ) + expect(result.stderr).toContain("run ended with an auto-rejected permission; pass --auto for autonomous use") }), 60_000, ) - cliIt.live( + cliIt.concurrent( "auto-rejected permission exits nonzero with stderr diagnostic under --format json", ({ llm, opencode }) => Effect.gen(function* () { yield* llm.tool("bash", { command: "sed -n 1,5p README.md" }) const result = yield* opencode.run("run sed on readme", { format: "json", - timeoutMs: 45_000, + timeoutMs: 75_000, }) opencode.expectExit(result, 1) - expect(result.stderr).toContain( - "run ended with an auto-rejected permission; pass --auto for autonomous use", - ) - const events = opencode.parseJsonEvents(result.stdout) - expect(events.some((event) => event.type === "error")).toBe(true) + expect(result.stderr).toContain("run ended with an auto-rejected permission; pass --auto for autonomous use") + expect(opencode.parseJsonEvents(result.stdout).some((event) => event.type === "error")).toBe(true) }), - 60_000, + 90_000, ) // kilocode_change end - // kilocode_change start // --format json puts one JSON object per line on stdout for each emitted // event. Consumers (CI scripts, tooling) parse this stream. Asserts the // shape so a future event-emit change has to update this expectation. - cliIt.live( + cliIt.concurrent( "--format json emits parseable line-delimited JSON to stdout", ({ llm, opencode }) => Effect.gen(function* () { yield* llm.text("structured output") - const result = yield* opencode.run("say hi", { format: "json", extraArgs: ["--auto"] }) + const result = yield* opencode.run("say hi", { format: "json" }) opencode.expectExit(result, 0) - expect(result.stdout).not.toContain("auto-rejected permission") const events = opencode.parseJsonEvents(result.stdout) expect(events.length).toBeGreaterThan(0) @@ -140,27 +184,232 @@ describe("opencode run (non-interactive subprocess)", () => { expect(typeof evt.type).toBe("string") expect(typeof evt.sessionID).toBe("string") } - expect(events.filter((event) => event.type === "step_start")).toHaveLength(1) - expect(events.filter((event) => event.type === "text")).toHaveLength(1) - expect(events.filter((event) => event.type === "step_finish")).toHaveLength(1) + expect(events.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"]) + expect(events.map(({ timestamp: _, sessionID: __, ...event }) => event)).toEqual([ + { type: "step_start", part: expect.objectContaining({ type: "step-start" }) }, + { + type: "text", + part: expect.objectContaining({ type: "text", text: "structured output" }), + }, + { type: "step_finish", part: expect.objectContaining({ type: "step-finish" }) }, + ]) + expect(result.stdout.endsWith("\n")).toBe(true) + expect( + result.stdout + .split("\n") + .slice(0, -1) + .every((line) => line.length > 0), + ).toBe(true) + }), + 60_000, + ) + + cliIt.concurrent( + "--format json emits a pure error record for a rejected prompt request", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.run("use an unknown model", { + model: "test/nonexistent-model", + format: "json", + }) + + expect(result.exitCode).not.toBe(0) + const events = opencode.parseJsonEvents(result.stdout) + // kilocode_change - upstream expects a single record. Kilo emits two, and has since before + // this merge: session/prompt.ts getModel publishes a readable "Model not found" session.error + // and then dies, and the die is masked into the generic request failure. Upstream only has the + // masked one, and asserts shape rather than message, so its count is one. Assert both records + // keep the record shape and that the readable message is the one a caller can act on. + expect(events.map((event) => event.type)).toEqual(["error", "error"]) + for (const event of events) { + expect(event).toEqual({ + type: "error", + timestamp: expect.any(Number), + sessionID: expect.any(String), + error: expect.any(Object), + }) + } + expect(JSON.stringify(events)).toContain("Model not found: test/nonexistent-model") + expect(result.stdout.split("\n").filter(Boolean)).toHaveLength(2) + }), + 30_000, + ) + + cliIt.concurrent( + "--format json preserves reasoning, tool, and continuation ordering", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().reason("reasoning").text("before").tool("bash", { + command: "printf tool", + description: "Print deterministic output", + }), + ) + yield* llm.text("after") + + const result = yield* opencode.run("exercise json records", { + format: "json", + extraArgs: ["--thinking", "--dangerously-skip-permissions"], + }) + + expect(result.exitCode).toBe(0) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.map((event) => event.type)).toEqual([ + "step_start", + "reasoning", + "text", + "tool_use", + "step_finish", + "step_start", + "text", + "step_finish", + ]) + expect(events.find((event) => event.type === "reasoning")?.part).toEqual( + expect.objectContaining({ type: "reasoning", text: "reasoning" }), + ) + expect(events.find((event) => event.type === "tool_use")?.part).toEqual( + expect.objectContaining({ + type: "tool", + tool: "bash", + state: expect.objectContaining({ status: "completed" }), + }), + ) + expect( + result.stdout + .split("\n") + .slice(0, -1) + .every((line) => line.startsWith("{")), + ).toBe(true) + }), + 60_000, + ) + + cliIt.concurrent( + "--format json records partial output for an unknown stream finish", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().text("partial json").tool("bash", { + command: "printf tool", + description: "Print deterministic output", + }), + ) + yield* llm.fail("provider failed") + // kilocode_change - settle bash up front; see the note on the reason assertion below + const result = yield* opencode.run("fail after output", { format: "json", permission: { bash: "deny" } }) + + const events = opencode.parseJsonEvents(result.stdout) + expect(result.exitCode).toBe(0) + expect(events.map((event) => event.type)).toEqual([ + "step_start", + "tool_use", + "text", // kilocode_change - a pre-denied tool settles before the SDK closes its preceding text part + "step_finish", + "step_start", + "step_finish", + ]) + // kilocode_change start - the pre-denied tool completes before text-end + expect(events.find((event) => event.type === "text")?.part).toEqual( + expect.objectContaining({ type: "text", text: "partial json" }), + ) + // kilocode_change end + // kilocode_change - upstream asserts reason "unknown" here. Reaching that requires the bash call + // to proceed without permission friction, which a Kilo headless run never does: left alone the ask + // is auto-rejected (exit 1, no second step), and settling it up front changes the request sequence + // so the queued stream error is not what ends the turn. The reason is left unasserted rather than + // pinned to a value produced by a different sequence; partial output, the named subject, still holds. + expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish" })) + }), + 60_000, + ) + + cliIt.concurrent( + "auto-rejects requested permissions by default and allows them with the dangerous flag", // kilocode_change + ({ home, llm, opencode }) => + Effect.gen(function* () { + yield* llm.tool("bash", { command: "rm -f denied-file", description: "Remove a test file" }) + yield* llm.text("continued after rejection") + const denied = yield* opencode.run("request permission", { permission: { bash: "ask" } }) + opencode.expectExit(denied, 1) // kilocode_change + expect(denied.stderr).toContain("permission requested: bash") + expect(denied.stderr).toContain("run ended with an auto-rejected permission; pass --auto for autonomous use") // kilocode_change + expect(denied.stdout).toBe("") + + yield* llm.reset + yield* llm.tool("bash", { command: "rm -f allowed-file", description: "Remove a test file" }) + yield* llm.text("continued after approval") + const allowed = yield* opencode.run("request permission", { + permission: { bash: "ask" }, + extraArgs: ["--dangerously-skip-permissions"], + }) + opencode.expectExit(allowed, 0) + expect(allowed.stderr).not.toContain("permission requested: bash") + expect(allowed.stdout).toContain("continued after approval") + + yield* llm.reset + yield* llm.tool("bash", { command: "touch explicitly-denied", description: "Create a denied marker" }) + yield* llm.text("continued after explicit denial") + const explicitlyDenied = yield* opencode.run("request denied permission", { + permission: { bash: "deny" }, + extraArgs: ["--dangerously-skip-permissions"], + }) + opencode.expectExit(explicitlyDenied, 0) + expect(explicitlyDenied.stdout).toContain("continued after explicit denial") + expect(yield* Effect.promise(() => Bun.file(`${home}/explicitly-denied`).exists())).toBe(false) }), 60_000, ) cliIt.live( - "--format json emits each completed tool once", - ({ llm, opencode }) => + "attach mode sends client-local file contents without a shared path", + ({ home, llm, opencode }) => Effect.gen(function* () { - yield* llm.tool("glob", { pattern: "package.json" }) - yield* llm.text("tool complete") - const result = yield* opencode.run("find package.json", { format: "json", extraArgs: ["--auto"] }) - opencode.expectExit(result, 0) + const source = `${home}/client-only.txt` + const sentinel = "client-only attachment sentinel" + yield* Effect.promise(() => Bun.write(source, sentinel)) + yield* llm.text("attachment received") + const server = yield* opencode.serve() - const events = opencode.parseJsonEvents(result.stdout) - expect(events.filter((event) => event.type === "tool_use")).toHaveLength(1) - expect(events.filter((event) => event.type === "text")).toHaveLength(1) + const result = yield* opencode.run("read the attachment", { + extraArgs: ["--attach", server.url, `--file=${source}`, "--"], + }) + + opencode.expectExit(result, 0) + const input = JSON.stringify(yield* llm.inputs) + expect(input).toContain(sentinel) + expect(input).not.toContain(`file://${source}`) }), 60_000, ) - // kilocode_change end + + cliIt.concurrent( + "attach mode rejects local directories before prompt admission", + ({ home, opencode }) => + Effect.gen(function* () { + const result = yield* opencode.run("read the directory", { + extraArgs: ["--attach", "http://127.0.0.1:1", `--file=${home}`, "--"], + }) + + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain("Cannot attach local directory without a shared filesystem") + }), + 30_000, + ) + + cliIt.live( + "SIGINT interrupts an active non-interactive run without leaking the process", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.hang + const run = yield* opencode.startRun("wait forever") + yield* llm.wait(1) + const interrupted = Date.now() // kilocode_change - assert signal handling, independent of contended CLI startup + run.interrupt() + const result = yield* run.result + + expect(result.exitCode).not.toBe(0) + expect(Date.now() - interrupted).toBeLessThan(10_000) // kilocode_change + }), + 60_000, // kilocode_change + ) }) diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index c9b1d1a92a4..8e57a06b4da 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -589,6 +589,38 @@ test("coalesces same-line tool progress into one snapshot", async () => { } }) +test("omits the current directory from bash titles", async () => { + const out = await setup() + + try { + await out.scrollback.append( + toolCommit({ + tool: "bash", + phase: "start", + toolState: "running", + state: { + status: "running", + input: { + command: "pwd", + workdir: process.cwd(), + }, + time: { start: 1 }, + }, + }), + ) + + const commits = claim(out.renderer) + try { + expect(render(commits)).toContain("$ pwd") + expect(render(commits)).not.toContain("Running in .") + } finally { + destroy(commits) + } + } finally { + out.scrollback.destroy() + } +}) + test("renders completed bash output with one blank line after the command and before the next group", async () => { const out = await setup() @@ -615,7 +647,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1 }, }, @@ -633,7 +664,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1, end: 2 }, }, @@ -645,6 +675,7 @@ test("renders completed bash output with one blank line after the command and be take() const output = lines.join("\n") + expect(output).toContain("# Running in /tmp/demo\n$ git status") expect(output).toContain("$ git status\n\nOn branch demo") expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1") expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1") @@ -677,7 +708,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, time: { start: 1 }, }, @@ -695,7 +725,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"), title: "pwd; ls -la", @@ -755,7 +784,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, time: { start: 1 }, }, @@ -805,7 +833,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"), title: "ls", diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index 1b46fafe3b2..d266c091b59 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -435,7 +435,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, @@ -490,7 +489,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index 18ae82d4da6..e3356d18313 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -238,7 +238,6 @@ function shellAssistantMessage(id: string, parentID: string): SessionMessages[nu title: "", metadata: { output: "account.ts\n", - description: "", }, time: { start: 200, diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index ee9bb07325e..3de324b5e4e 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -1,5 +1,6 @@ import path from "path" import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { describe, expect, test } from "bun:test" import { Effect, FileSystem, Layer } from "effect" @@ -98,7 +99,7 @@ function userMessage( } } -const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, NodeFileSystem.layer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(FSUtil.node), NodeFileSystem.layer)) function remap(root: string, file: string) { if (file === Global.Path.state) { @@ -123,7 +124,7 @@ function remappedFs(root: string) { writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } describe("run variant shared", () => { diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index f79fd40da74..785ced9c912 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" +import yargs from "yargs" import { tmpdir } from "../../fixture/fixture" -import { resolveThreadDirectory } from "../../../src/cli/cmd/tui" +import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui" +import { cliIt } from "../../lib/cli-process" describe("tui thread", () => { test("loads the TUI integration lazily", async () => { @@ -33,4 +36,60 @@ describe("tui thread", () => { test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) + + test("ignores stale PWD when resolving a relative mini project", async () => { // kilocode_change + await using pwd = await tmpdir({ git: true }) + await using cwd = await tmpdir({ git: true }) + + expect(resolveThreadDirectory(".", pwd.path, cwd.path)).toBe(cwd.path) // kilocode_change + expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) + }) + + test("parses supported --no-replay forms", async () => { + for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) { + const args = await yargs([]) + .command({ ...TuiThreadCommand, handler: () => {} }) + .exitProcess(false) + .parse(["--mini", option, "--replay-limit", "10"]) + + expect(args.replay === false || args.noReplay === true).toBe(true) + expect(args.replayLimit).toBe(10) + } + }) + + test("preserves boolean negation for existing options", async () => { + const args = await yargs([]) + .command({ ...TuiThreadCommand, handler: () => {} }) + .exitProcess(false) + .parse(["--mdns", "--no-mdns"]) + + expect(args.mdns).toBe(false) + }) + + cliIt.live("rejects mini-only options without --mini", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--replay-limit", "10"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--replay-limit requires --mini") + }), + ) + + cliIt.live("routes attached sessions to mini mode", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--mini requires a TTY stdout") + }), + ) + + cliIt.live("rejects network options in mini mode", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--mini", "--port", "4096"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--port cannot be used with --mini") + }), + ) }) diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index 664170696ef..6b27cafe85a 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,11 +1,11 @@ import { expect } from "bun:test" -import { Effect, Layer } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { Config } from "@/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Config.node, AgentSvc.node]))) it.instance( "agent color parsed from project config", diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 34b0ee9f665..6b714cc9281 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,13 +1,14 @@ import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Cause, Effect, Exit, Layer, Option } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" -import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Npm } from "@opencode-ai/core/npm" import { InstanceRef } from "../../src/effect/instance-ref" import type { InstanceContext } from "../../src/project/instance-context" @@ -44,13 +45,6 @@ import { NpmTest } from "../fake/npm" import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect" // kilocode_change import { isAtomicChatPlugin } from "@/kilocode/atomic-chat-feature" // kilocode_change -/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ -const infra = CrossSpawnSpawner.defaultLayer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) - -const testFlock = EffectFlock.defaultLayer - const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) @@ -107,17 +101,12 @@ const configLayer = ( client?: HttpClient.HttpClient } = {}, ) => - Config.layer.pipe( - Layer.provide(Git.defaultLayer), // kilocode_change - Layer.provide(testFlock), - Layer.provide(Env.defaultLayer), - Layer.provide(options.auth ?? AuthTest.empty), - Layer.provide(options.account ?? AccountTest.empty), - Layer.provideMerge(infra), - Layer.provide(NpmTest.noop), - Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)), - Layer.provideMerge(FSUtil.defaultLayer), - ) + LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [ + [Auth.node, options.auth ?? AuthTest.empty], + [Account.node, options.account ?? AccountTest.empty], + [Npm.node, NpmTest.noop], + [httpClient, Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)], + ]) const layer = configLayer() @@ -180,7 +169,7 @@ const withInstanceDir = (dir: string, effect: Effect.Effect) = Effect.provideService(TestInstance, { directory: dir }), provideInstanceEffect(dir), Effect.provide(testInstanceStoreLayer), - Effect.provide(CrossSpawnSpawner.defaultLayer), + Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)), ) const withGlobalConfigDir = (dir: string, effect: Effect.Effect) => @@ -334,7 +323,7 @@ it.effect("creates global jsonc config with schema when no global configs exist" const content = yield* FSUtil.use.readFileString(path.join(dir, "kilo.jsonc")) // kilocode_change expect(content).toContain('"$schema": "https://app.kilo.ai/config.json"') // kilocode_change - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ), ) @@ -349,7 +338,7 @@ it.effect("does not create global config when KILO_CONFIG_DIR is set", () => yield* Config.use.get().pipe(provideInstanceEffect(dir)) expect(yield* FSUtil.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false) - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ), ) }), @@ -582,10 +571,7 @@ it.instance("injects $schema into config without existing schema", () => Effect.gen(function* () { const test = yield* TestInstance // Config without $schema - should trigger auto-add - yield* FSUtil.use.writeWithDirs( - path.join(test.directory, "kilo.json"), - JSON.stringify({ username: "test-user" }), - ) + yield* FSUtil.use.writeWithDirs(path.join(test.directory, "kilo.json"), JSON.stringify({ username: "test-user" })) const config = yield* Config.use.get() expect(config.username).toBe("test-user") expect(config.$schema).toBe("https://app.kilo.ai/config.json") @@ -1141,7 +1127,7 @@ it.effect("does not try to install dependencies in read-only KILO_CONFIG_DIR", ( yield* Effect.addFinalizer(() => FSUtil.use.chmod(readonly, 0o755).pipe(Effect.ignore)) yield* withProcessEnv("KILO_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir))) - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ) it.effect("installs dependencies in writable KILO_CONFIG_DIR", () => @@ -1159,7 +1145,7 @@ it.effect("installs dependencies in writable KILO_CONFIG_DIR", () => ) expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json") - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ) // Note: deduplication and serialization of npm installs is now handled by the @@ -1822,17 +1808,12 @@ test("remote well-known config can use FetchHttpClient layer", async () => { Effect.scoped, Effect.provide( Layer.mergeAll( - Config.layer.pipe( - Layer.provide(Git.defaultLayer), // kilocode_change - Layer.provide(testFlock), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(wellKnownAuth(server.url.origin)), - Layer.provide(AccountTest.empty), - Layer.provideMerge(infra), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), - ), + LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [ + [Auth.node, wellKnownAuth(server.url.origin)], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [httpClient, FetchHttpClient.layer], + ]), testInstanceStoreLayer, ), ), diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 48f050d1a95..b61e695f186 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,6 +1,8 @@ import { expect } from "bun:test" import path from "path" import { pathToFileURL } from "url" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" @@ -12,7 +14,7 @@ import { TuiConfig } from "../../src/config/tui" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Config.defaultLayer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Config.node, FSUtil.node]))) const winIt = process.platform === "win32" ? it.instance : it.instance.skip const globalConfigFiles = ["kilo.json", "kilo.jsonc", "tui.json", "tui.jsonc"].map((file) => @@ -79,12 +81,16 @@ const withPlatform = (platform: typeof process.platform, self: Effect.E const getTuiConfig = (directory: string) => TuiConfig.Service.use((svc) => svc.get()).pipe( - Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide( + AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))), + ), ) const getTuiPluginOrigins = (directory: string) => TuiConfig.Service.use((svc) => svc.pluginOrigins()).pipe( - Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide( + AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))), + ), ) it.instance("keeps server and tui plugin merge semantics aligned", () => @@ -482,6 +488,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () => keybinds: { leader: { key: { name: "g", ctrl: true } }, command_list: "alt+p", + diff_open: "ctrl+j", which_key_toggle: "alt+k", editor_open: "ctrl+e", "prompt.autocomplete.next": "ctrl+j", @@ -497,6 +504,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () => expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true }) expect(config.leader_timeout).toBe(1234) expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p") + expect(config.keybinds.get("diff.open")?.[0]?.key).toBe("ctrl+j") expect(config.keybinds.get("session.new")?.[0]?.key).toBe("n") expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k") expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k") diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index ecf5cd97f2d..fafbeb7e4a9 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -5,9 +5,8 @@ import Http from "node:http" import path from "node:path" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Exit, Fiber, Layer, Schema } from "effect" -import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" -import { FSUtil } from "@opencode-ai/core/fs-util" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Database } from "@opencode-ai/core/database/database" import { ProjectV2 } from "@opencode-ai/core/project" @@ -16,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Session as SessionNs } from "@/session/session" import { SessionID } from "@/session/schema" import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { EventSequenceTable } from "@opencode-ai/core/event/sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideTmpdirInstance, requireInstance, TestInstance } from "../fixture/fixture" @@ -27,13 +27,10 @@ import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control- import * as Workspace from "../../src/control-plane/workspace" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" -import { Auth } from "@/auth" -import { SessionPrompt } from "@/session/prompt" -import { Project } from "@/project/project" -import { Vcs } from "@/project/vcs" import { RuntimeFlags } from "@/effect/runtime-flags" -import { EventV2Bridge } from "@/event-v2-bridge" import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const originalEnv = { KILO_AUTH_CONTENT: process.env.KILO_AUTH_CONTENT, @@ -44,26 +41,27 @@ const originalEnv = { } const workspaceLayer = (experimentalWorkspaces: boolean) => - Workspace.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))), + AppNodeBuilder.build( + LayerNode.group([ + Workspace.node, + SessionNs.node, + SessionProjector.node, + Database.node, + InstanceStore.node, + Ripgrep.node, + ]), + [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })], + [ + InstanceStore.bootstrapNode, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], + ], ) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), workspaceLayer(true), - SessionNs.defaultLayer, - Database.defaultLayer, ) const it = testEffect(testServerLayer) diff --git a/packages/opencode/test/effect/app-graph-types.test.ts b/packages/opencode/test/effect/app-graph-types.test.ts deleted file mode 100644 index 527c4daf54c..00000000000 --- a/packages/opencode/test/effect/app-graph-types.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -class A extends Context.Service()("test/A") {} -class B extends Context.Service()("test/B") {} -class C extends Context.Service()("test/C") {} -class LayerError { - readonly _tag = "LayerError" -} -class NotFoundError { - readonly _tag = "NotFoundError" -} -class DiskError { - readonly _tag = "DiskError" -} -class NetworkError { - readonly _tag = "NetworkError" -} - -const aImplementation = Layer.succeed(A, A.of({ value: "a" })) -const bImplementation = Layer.effect( - B, - Effect.gen(function* () { - yield* A - return B.of({ value: "b" }) - }), -) -const cImplementation = Layer.effect( - C, - Effect.gen(function* () { - yield* A - yield* B - return C.of({ value: "c" }) - }), -) -const failingAImplementation = Layer.effect(A, Effect.fail(new LayerError())) -const notFoundAImplementation = Layer.effect(A, Effect.fail(new NotFoundError())) -const diskAImplementation = Layer.effect(A, Effect.fail(new DiskError())) -const networkAImplementation = Layer.effect(A, Effect.fail(new NetworkError())) -const notFoundOrDiskAImplementation = Layer.effect(A, Effect.fail(new NotFoundError() as NotFoundError | DiskError)) - -type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Assert = T - -type AProvides = Assert, A>> -type ARequires = Assert, never>> -type BProvides = Assert, B>> -type BRequires = Assert, A>> -type CRequires = Assert, A | B>> -void (0 as unknown as AProvides) -void (0 as unknown as ARequires) -void (0 as unknown as BProvides) -void (0 as unknown as BRequires) -void (0 as unknown as CRequires) - -const a = LayerNode.make(aImplementation, []) -const b = LayerNode.make(bImplementation, [a]) -const c = LayerNode.make(cImplementation, [a, b]) -const failingA = LayerNode.make(failingAImplementation, []) -const bWithFailingA = LayerNode.make(bImplementation, [failingA]) -const notFoundA = LayerNode.make(notFoundAImplementation, []) -const diskA = LayerNode.make(diskAImplementation, []) -const networkA = LayerNode.make(networkAImplementation, []) -const notFoundOrDiskA = LayerNode.make(notFoundOrDiskAImplementation, []) - -// @ts-expect-error B requires A -LayerNode.make(bImplementation, []) - -// @ts-expect-error C requires both A and B -LayerNode.make(cImplementation, [a]) - -type ANodeProvides = Assert>> -type BNodeProvides = Assert>> -type CNodeProvides = Assert>> -type FailingANodeError = Assert>> -type DependentNodeError = Assert>> -void (0 as unknown as ANodeProvides) -void (0 as unknown as BNodeProvides) -void (0 as unknown as CNodeProvides) -void (0 as unknown as FailingANodeError) -void (0 as unknown as DependentNodeError) - -const closed = LayerNode.buildLayer(c) -const closedWithError = LayerNode.buildLayer(bWithFailingA) -type ClosedProvides = Assert, C>> -type ClosedRequires = Assert, never>> -type ClosedError = Assert, LayerError>> -void (0 as unknown as ClosedProvides) -void (0 as unknown as ClosedRequires) -void (0 as unknown as ClosedError) - -const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), []) -LayerNode.replace(a, Layer.succeed(A, A.of({ value: "a" }))) -LayerNode.replace(notFoundOrDiskA, notFoundAImplementation) -LayerNode.replace(notFoundOrDiskA, diskAImplementation) -LayerNode.replaceWithNode(a, replacement) - -// @ts-expect-error An override for A must still provide A -LayerNode.replaceWithNode(a, b) - -// @ts-expect-error A replacement cannot introduce NetworkError -LayerNode.replace(notFoundOrDiskA, networkAImplementation) - -// @ts-expect-error A replacement layer must not have unresolved dependencies -LayerNode.replace(b, bImplementation) - -test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/effect/app-graph.test.ts b/packages/opencode/test/effect/app-graph.test.ts deleted file mode 100644 index 7ae7a982ba7..00000000000 --- a/packages/opencode/test/effect/app-graph.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Cause, Context, Effect, Exit, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -const { buildLayer: build, group, replace, replaceWithNode } = LayerNode -const node = LayerNode.make - -class Value extends Context.Service()("test/Value") {} -class Greeting extends Context.Service()("test/Greeting") {} - -const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), []) -const greetingImplementation = Layer.effect( - Greeting, - Effect.gen(function* () { - return Greeting.of({ text: `hello ${(yield* Value).value}` }) - }), -) -const greeting = LayerNode.make(greetingImplementation, [value]) - -// @ts-expect-error Greeting requires Value -LayerNode.make(greetingImplementation, []) - -describe("app graph", () => { - test("creates any selected dependency layer", async () => { - const result = Effect.gen(function* () { - return (yield* Greeting).text - }).pipe(Effect.provide(build(greeting))) - - expect(await Effect.runPromise(result)).toBe("hello production") - }) - - test("applies overrides before dependency materialization", async () => { - const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) - const graph = build(greeting, { replacements: [replace(value, replacement)] }) - const result = Effect.gen(function* () { - return (yield* Greeting).text - }).pipe(Effect.provide(graph)) - - expect(await Effect.runPromise(result)).toBe("hello simulation") - }) - - test("acquires a shared dependency once", async () => { - class Shared extends Context.Service()("test/Shared") {} - class Left extends Context.Service()("test/Left") {} - class Right extends Context.Service()("test/Right") {} - let acquisitions = 0 - const shared = node( - Layer.effect( - Shared, - Effect.sync(() => { - acquisitions++ - return Shared.of({ value: "shared" }) - }), - ), - [], - ) - const left = node( - Layer.effect( - Left, - Effect.gen(function* () { - return Left.of({ value: `${(yield* Shared).value}-left` }) - }), - ), - [shared], - ) - const right = node( - Layer.effect( - Right, - Effect.gen(function* () { - return Right.of({ value: `${(yield* Shared).value}-right` }) - }), - ), - [shared], - ) - - const result = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(build(group([left, right])))) - - expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"]) - expect(acquisitions).toBe(1) - }) - - test("applies a replacement to every transitive consumer", async () => { - class Left extends Context.Service()("test/ReplacementLeft") {} - class Right extends Context.Service()("test/ReplacementRight") {} - const left = node( - Layer.effect( - Left, - Effect.gen(function* () { - return Left.of({ value: (yield* Value).value }) - }), - ), - [value], - ) - const right = node( - Layer.effect( - Right, - Effect.gen(function* () { - return Right.of({ value: (yield* Value).value }) - }), - ), - [value], - ) - const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) - const graph = build(group([left, right]), { replacements: [replace(value, replacement)] }) - - const result = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(graph)) - - expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"]) - }) - - test("propagates layer acquisition errors", async () => { - class AcquisitionError { - readonly _tag = "AcquisitionError" - } - const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), []) - const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing))) - - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError) - }) - - test("groups expose every selected service", async () => { - class Count extends Context.Service()("test/Count") {} - const count = node(Layer.succeed(Count, Count.of({ value: 3 })), []) - const result = Effect.gen(function* () { - return { text: (yield* Value).value, count: (yield* Count).value } - }).pipe(Effect.provide(build(group([value, count])))) - - expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 }) - }) - - test("builds an empty group", async () => { - expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok") - }) - - test("builds replacements with their own dependencies", async () => { - class ReplacementConfig extends Context.Service()( - "test/ReplacementConfig", - ) {} - const replacementConfig = node(Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })), []) - const replacement = node( - Layer.effect( - Value, - Effect.gen(function* () { - return Value.of({ value: (yield* ReplacementConfig).value }) - }), - ), - [replacementConfig], - ) - const result = Effect.gen(function* () { - return (yield* Greeting).text - }).pipe(Effect.provide(build(greeting, { replacements: [replaceWithNode(value, replacement)] }))) - - expect(await Effect.runPromise(result)).toBe("hello replacement") - }) - - test("does not acquire unreachable replacements", async () => { - let acquisitions = 0 - const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), []) - const replacement = Layer.effect( - Value, - Effect.sync(() => { - acquisitions++ - return Value.of({ value: "replacement" }) - }), - ) - - await Effect.runPromise( - Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] })), - ) - - expect(acquisitions).toBe(0) - }) - - test("rejects a direct cycle", () => { - const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), []) - ;(cyclic.dependencies as LayerNode.Node[]).push(cyclic) - - expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1") - }) - - test("rejects an indirect cycle", () => { - const first = node(Layer.succeed(Value, Value.of({ value: "first" })), []) - const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first]) - const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second]) - ;(first.dependencies as LayerNode.Node[]).push(third) - - expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1") - }) - - test("rejects a cycle introduced by a replacement", () => { - const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), []) - const consumer = node(greetingImplementation, [value]) - ;(replacement.dependencies as LayerNode.Node[]).push(consumer) - - expect(() => build(consumer, { replacements: [replaceWithNode(value, replacement)] })).toThrow( - "Cycle detected in app graph: layer#1 -> layer#2 -> layer#1", - ) - }) -}) diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index ecebe504469..a0c34244f0d 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppLayer } from "../../src/effect/app-runtime" @@ -9,7 +10,8 @@ import { attach } from "../../src/effect/run-service" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(CrossSpawnSpawner.defaultLayer) +const observabilityLayer = LayerNode.compile(Observability.node) +const it = testEffect(LayerNode.compile(CrossSpawnSpawner.node)) function check(loggers: ReadonlySet>) { return { @@ -34,7 +36,7 @@ it.live("makeRuntime installs the observability logger", () => ) const current = yield* Dummy.use((svc) => svc.current()).pipe( - Effect.provide(Layer.provideMerge(layer, Observability.layer)), + Effect.provide(Layer.provideMerge(layer, observabilityLayer)), ) expect(current.size).toBeGreaterThan(0) @@ -94,6 +96,6 @@ it.instance( expect(result.directory).toBe(test.directory) expect(result.size).toBeGreaterThan(0) - }).pipe(Effect.provide(Observability.layer)), + }).pipe(Effect.provide(observabilityLayer)), { git: true }, ) diff --git a/packages/opencode/test/effect/config-service.test.ts b/packages/opencode/test/effect/config-service.test.ts index be6f9773631..0a1fc605eaa 100644 --- a/packages/opencode/test/effect/config-service.test.ts +++ b/packages/opencode/test/effect/config-service.test.ts @@ -10,12 +10,12 @@ class TestConfig extends ConfigService.Service()("@test/ConfigServic }) {} const fromConfig = (input: Record) => - TestConfig.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + TestConfig.layer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) const readConfig = TestConfig.useSync((config) => config) describe("ConfigService", () => { - it.effect("defaultLayer parses values from the active ConfigProvider", () => + it.effect("layer parses values from the active ConfigProvider", () => Effect.gen(function* () { const config = yield* readConfig.pipe( Effect.provide( @@ -33,7 +33,7 @@ describe("ConfigService", () => { }), ) - it.effect("defaultLayer applies Effect Config defaults", () => + it.effect("layer applies Effect Config defaults", () => Effect.gen(function* () { const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" }))) @@ -47,7 +47,7 @@ describe("ConfigService", () => { Effect.gen(function* () { const config = yield* readConfig.pipe( Effect.provide( - TestConfig.layer({ + TestConfig.configLayer({ name: "direct", token: Option.some("parsed"), port: 9000, diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index d983de89a85..148eca8d5c6 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -1,5 +1,6 @@ import { expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" @@ -12,7 +13,7 @@ import { } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(CrossSpawnSpawner.node), testInstanceStoreLayer)) const access = (state: InstanceState.InstanceState, dir: string) => InstanceState.get(state).pipe(provideInstanceEffect(dir)) diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index cb3178b86fa..037b88aa85d 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -1,15 +1,16 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { it } from "../lib/effect" const fromConfig = (input: Record) => - RuntimeFlags.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + AppNodeBuilder.build(RuntimeFlags.node).pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) const readFlags = RuntimeFlags.Service.useSync((flags) => flags) describe("RuntimeFlags", () => { - it.effect("defaultLayer defaults autoShare to false", () => + it.effect("layer defaults autoShare to false", () => Effect.gen(function* () { const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) @@ -17,7 +18,7 @@ describe("RuntimeFlags", () => { }), ) - it.effect("defaultLayer parses plugin flags from the active ConfigProvider", () => + it.effect("layer parses plugin flags from the active ConfigProvider", () => Effect.gen(function* () { const flags = yield* readFlags.pipe( Effect.provide( @@ -64,7 +65,7 @@ describe("RuntimeFlags", () => { }), ) - it.effect("defaultLayer parses KILO_EXPERIMENTAL_LSP_TY", () => + it.effect("layer parses KILO_EXPERIMENTAL_LSP_TY", () => Effect.gen(function* () { const flags = yield* readFlags.pipe( Effect.provide( diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts new file mode 100644 index 00000000000..be22f5648ae --- /dev/null +++ b/packages/opencode/test/event-manifest.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { EventManifest as SchemaEventManifest } from "@opencode-ai/schema/event-manifest" +import { Todo } from "@/session/todo" +import { EventManifest } from "@/event-manifest" + +describe("public event manifest", () => { + test("contains every latest public wire type once", () => { + expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) + expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) + expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) + expect(EventManifest.Latest.size).toBe(89) // kilocode_change - include global.config.updated + expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) + expect(EventManifest.Latest.has("ide.installed")).toBe(false) + expect(EventManifest.Latest.has("server.connected")).toBe(true) + expect(EventManifest.Latest.has("global.disposed")).toBe(true) + expect(EventManifest.Latest.has("global.config.updated")).toBe(true) // kilocode_change + }) + + test("contains only the current step settlement versions", () => { + expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) + expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + }) +}) diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts index 686a21d527f..d1ccc1f3eea 100644 --- a/packages/opencode/test/filesystem/filesystem.test.ts +++ b/packages/opencode/test/filesystem/filesystem.test.ts @@ -1,12 +1,11 @@ import { describe, test, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import path from "path" -const live = FSUtil.layer.pipe(Layer.provide(NodeFileSystem.layer)) -const { effect: it } = testEffect(live) +const { effect: it } = testEffect(LayerNode.compile(FSUtil.node)) describe("FSUtil", () => { describe("isDir", () => { diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 78bea2753ba..7a859134132 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -12,8 +12,10 @@ import { Database } from "@opencode-ai/core/database/database" // kilocode_chang import { ProjectV2 } from "@opencode-ai/core/project" // kilocode_change import { ProjectTable } from "@opencode-ai/core/project/sql" // kilocode_change import { AbsolutePath } from "@opencode-ai/core/schema" // kilocode_change +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { context as instanceContext, type InstanceContext } from "../../src/project/instance-context" // kilocode_change @@ -23,7 +25,9 @@ import { TestLLMServer } from "../lib/llm-server" import { remove as cleanup } from "../kilocode/cleanup" // kilocode_change const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) +export const testInstanceStoreLayer = LayerNode.compile(InstanceStore.node, [ + [InstanceStore.bootstrapNode, noopBootstrap], +]) const makeTestRuntime = () => ManagedRuntime.make(testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer))) let testRuntime: ReturnType | undefined const runtime = () => (testRuntime ??= makeTestRuntime()) @@ -259,7 +263,7 @@ export const withTmpdirInstance = Effect.gen(function* () { const directory = yield* tmpdirScoped(options) return yield* self.pipe(Effect.provideService(TestInstance, { directory }), provideInstanceEffect(directory)) - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) export function provideTmpdirServer( self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect, diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 06706bc96be..13860c0d0b4 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -97,6 +97,7 @@ type Opts = { state?: { ready?: HostPluginApi["state"]["ready"] config?: HostPluginApi["state"]["config"] + globalConfig?: HostPluginApi["state"]["globalConfig"] // kilocode_change provider?: HostPluginApi["state"]["provider"] path?: HostPluginApi["state"]["path"] vcs?: HostPluginApi["state"]["vcs"] @@ -303,6 +304,11 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { get config() { return opts.state?.config ?? {} }, + // kilocode_change start + get globalConfig() { + return opts.state?.globalConfig ?? {} + }, + // kilocode_change end get provider() { return opts.state?.provider ?? [] }, diff --git a/packages/opencode/test/fixture/workspace.ts b/packages/opencode/test/fixture/workspace.ts index 46335d33615..cf758557159 100644 --- a/packages/opencode/test/fixture/workspace.ts +++ b/packages/opencode/test/fixture/workspace.ts @@ -1,5 +1,5 @@ -import { FetchHttpClient } from "effect/unstable/http" -import { Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { FSUtil } from "@opencode-ai/core/fs-util" import { Auth } from "../../src/auth" @@ -14,17 +14,21 @@ import { SessionPrompt } from "../../src/session/prompt" import { EventV2Bridge } from "../../src/event-v2-bridge" export const workspaceLayerWithRuntimeFlags = (overrides: Partial) => - Workspace.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(RuntimeFlags.layer(overrides)), - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), + AppNodeBuilder.build( + LayerNode.group([ + Workspace.node, + Auth.node, + Session.node, + SessionPrompt.node, + Project.node, + Vcs.node, + Database.node, + EventV2Bridge.node, + FSUtil.node, + InstanceStore.node, + ]), + [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], + [RuntimeFlags.node, RuntimeFlags.layer(overrides)], + ], ) diff --git a/packages/opencode/test/fixtures/recordings/kilocode/session/native-openai-oauth-tool-loop.json b/packages/opencode/test/fixtures/recordings/kilocode/session/native-openai-oauth-tool-loop.json index 5f966e73aee..f0dec0ba526 100644 --- a/packages/opencode/test/fixtures/recordings/kilocode/session/native-openai-oauth-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/kilocode/session/native-openai-oauth-tool-loop.json @@ -22,7 +22,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" }, "response": { "status": 200, @@ -38,7 +38,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" }, "response": { "status": 200, diff --git a/packages/opencode/test/fixtures/recordings/kilocode/session/native-zen-tool-loop.json b/packages/opencode/test/fixtures/recordings/kilocode/session/native-zen-tool-loop.json index cf535b73a05..cf369d8c28e 100644 --- a/packages/opencode/test/fixtures/recordings/kilocode/session/native-zen-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/kilocode/session/native-zen-tool-loop.json @@ -22,7 +22,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" }, "response": { "status": 200, @@ -40,7 +40,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" }, "response": { "status": 200, diff --git a/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json index 625140f9918..7194987fce9 100644 --- a/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json @@ -17,7 +17,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" }, "response": { "status": 200, @@ -33,7 +33,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json index afcfd9edc17..dfb8632919c 100644 --- a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json @@ -17,7 +17,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, @@ -35,7 +35,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index e0388a7fd3e..72adc848f43 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -1,5 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { provideTmpdirInstance, testInstanceStoreLayer, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -7,7 +8,9 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Format } from "../../src/format" import * as Formatter from "../../src/format/formatter" -const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), NodeFileSystem.layer), +) describe("Format", () => { it.instance("status() returns empty list when no formatters are configured", () => @@ -106,7 +109,11 @@ describe("Format", () => { ) testEffect( - Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer), + Layer.mergeAll( + LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), + NodeFileSystem.layer, + testInstanceStoreLayer, + ), ).live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.use.status(), { diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index e80b8fa9065..56f19a4a460 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -1,4 +1,5 @@ import { $ } from "bun" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" @@ -8,7 +9,7 @@ import { tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -const it = testEffect(Git.defaultLayer) +const it = testEffect(LayerNode.compile(LayerNode.group([Git.node]))) const scopedTmpdir = (options?: Parameters[0]) => Effect.acquireRelease( diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts index bbcca75777c..a58641402d1 100644 --- a/packages/opencode/test/image/image.test.ts +++ b/packages/opencode/test/image/image.test.ts @@ -1,19 +1,17 @@ import { describe, expect, test } from "bun:test" -import { Cause, Effect, Exit, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Effect, Exit } from "effect" import { Image } from "@/image/image" +import { Config } from "@/config/config" import { MessageID, PartID, SessionID } from "@/session/schema" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer())))) +const it = testEffect(LayerNode.compile(Image.node, [[Config.node, TestConfig.layer()]])) const tiny = testEffect( - Layer.mergeAll( - Image.layer.pipe( - Layer.provide( - TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) }), - ), - ), - ), + LayerNode.compile(Image.node, [ + [Config.node, TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) })], + ]), ) function part(mime: string, data: string) { diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index b842319b6d3..4a4483b7020 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -1,10 +1,13 @@ import { describe, expect } from "bun:test" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" import { InstallationChannel } from "@opencode-ai/core/installation/version" -import { AppProcess } from "@opencode-ai/core/process" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" const encoder = new TextEncoder() @@ -52,8 +55,15 @@ function testLayer( httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response, spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string }, ) { - const appProcess = AppProcess.layer.pipe(Layer.provide(mockSpawner(spawnHandler))) - return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(appProcess)) + const spawnerNode = makeGlobalNode({ + service: ChildProcessSpawner.ChildProcessSpawner, + layer: mockSpawner(spawnHandler), + deps: [], + }) + return LayerNode.compile(Installation.node, [ + [httpClient, mockHttpClient(httpHandler)], + [CrossSpawnSpawner.node, spawnerNode], + ]) } describe("installation", () => { diff --git a/packages/opencode/test/kilocode/agent-global-config-dirs.test.ts b/packages/opencode/test/kilocode/agent-global-config-dirs.test.ts index ec59d9bd920..85cb67f61d3 100644 --- a/packages/opencode/test/kilocode/agent-global-config-dirs.test.ts +++ b/packages/opencode/test/kilocode/agent-global-config-dirs.test.ts @@ -1,4 +1,5 @@ // kilocode_change - new file +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import { Effect } from "effect" import { testEffect } from "../lib/effect" @@ -6,7 +7,7 @@ import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" import { Global } from "@opencode-ai/core/global" -const it = testEffect(Agent.defaultLayer) +const it = testEffect(AppNodeBuilder.build(Agent.node)) it.instance("code agent allows global config directory reads by default", () => Effect.gen(function* () { diff --git a/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts index 75517c458e3..28134446dbd 100644 --- a/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" import { Effect, Layer, ManagedRuntime } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -49,9 +50,9 @@ const providers = { const runtime = ManagedRuntime.make( Layer.mergeAll( - Truncate.defaultLayer, - Agent.defaultLayer, - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), Layer.mock(Provider.Service, { list: () => Effect.succeed(providers) }), ), ) diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 974b5bf5430..ccba13be01d 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" import { Effect, Layer, ManagedRuntime, Queue } from "effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -66,10 +67,10 @@ const agent: Agent.Info = { function makeRuntime(defaultProviderID = "test", host: Partial = {}) { return ManagedRuntime.make( Layer.mergeAll( - Truncate.defaultLayer, + AppNodeBuilder.build(Truncate.node), Layer.mock(Agent.Service, { get: () => Effect.succeed(agent) }), - Bus.defaultLayer, - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(Bus.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), Layer.mock(AgentManager.Service, host), Layer.mock(Provider.Service, { list: () => Effect.succeed(providers), diff --git a/packages/opencode/test/kilocode/agent-permission-overrides.test.ts b/packages/opencode/test/kilocode/agent-permission-overrides.test.ts index 56fd8fb70c6..e890e8edb5a 100644 --- a/packages/opencode/test/kilocode/agent-permission-overrides.test.ts +++ b/packages/opencode/test/kilocode/agent-permission-overrides.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, expect, test } from "bun:test" import type { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { Effect } from "effect" @@ -9,7 +10,7 @@ import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdir } function load
(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { return Effect.runPromise( provideInstance(dir)(Agent.Service.use(fn)).pipe( - Effect.provide(Agent.defaultLayer), + Effect.provide(AppNodeBuilder.build(Agent.node)), Effect.provide(testInstanceStoreLayer), ), ) diff --git a/packages/opencode/test/kilocode/agent-routing.test.ts b/packages/opencode/test/kilocode/agent-routing.test.ts index 2ce79e53423..61f6e2c3c62 100644 --- a/packages/opencode/test/kilocode/agent-routing.test.ts +++ b/packages/opencode/test/kilocode/agent-routing.test.ts @@ -1,7 +1,8 @@ import { afterEach, expect, test } from "bun:test" import { Global } from "@opencode-ai/core/global" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import path from "path" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { Filesystem } from "../../src/util/filesystem" import { @@ -15,8 +16,7 @@ import { function load(dir: string) { return Effect.runPromise( provideInstance(dir)(Agent.Service.use((svc) => svc.get("architect"))).pipe( - Effect.provide(Agent.defaultLayer), - Effect.provide(testInstanceStoreLayer), + Effect.provide(Layer.mergeAll(AppNodeBuilder.build(Agent.node), testInstanceStoreLayer)), ), ) } diff --git a/packages/opencode/test/kilocode/agent-skill-permissions.test.ts b/packages/opencode/test/kilocode/agent-skill-permissions.test.ts index 4cc5ad6e07f..f2ebc2ed4d3 100644 --- a/packages/opencode/test/kilocode/agent-skill-permissions.test.ts +++ b/packages/opencode/test/kilocode/agent-skill-permissions.test.ts @@ -1,11 +1,12 @@ // kilocode_change - new file +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import { Effect } from "effect" import { testEffect } from "../lib/effect" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" -const it = testEffect(Agent.defaultLayer) +const it = testEffect(AppNodeBuilder.build(Agent.node)) function action(name: string, ruleset: Permission.Ruleset) { return Permission.evaluate("skill", name, ruleset).action diff --git a/packages/opencode/test/kilocode/auth-default-layer.test.ts b/packages/opencode/test/kilocode/auth-default-layer.test.ts new file mode 100644 index 00000000000..02f6f5303b8 --- /dev/null +++ b/packages/opencode/test/kilocode/auth-default-layer.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test" +import { Effect } from "effect" +import { Auth } from "@/auth" + +test("legacy auth runtime layer remains buildable", async () => { + const result = await Effect.runPromise( + Auth.Service.use(() => Effect.succeed("ready")).pipe(Effect.provide(Auth.defaultLayer)), + ) + + expect(result).toBe("ready") +}) diff --git a/packages/opencode/test/kilocode/auth-remove.test.ts b/packages/opencode/test/kilocode/auth-remove.test.ts index 27619f4f04e..9eb6fa9ea96 100644 --- a/packages/opencode/test/kilocode/auth-remove.test.ts +++ b/packages/opencode/test/kilocode/auth-remove.test.ts @@ -1,7 +1,7 @@ import { expect } from "bun:test" import { Auth } from "@/auth" import { remove } from "@/kilocode/auth/remove" -import { IntegrationSchema } from "@opencode-ai/core/integration/schema" +import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" @@ -10,8 +10,7 @@ import { testEffect } from "../lib/effect" const database = Database.layerFromPath(":memory:") const events = Layer.mock(EventV2.Service)({ - publish: (definition, data) => - Effect.succeed({ id: EventV2.ID.create(), type: definition.type, data }), + publish: (definition, data) => Effect.succeed({ id: EventV2.ID.create(), type: definition.type, data }), }) const credentials = Credential.layer.pipe(Layer.provide(database), Layer.provide(events)) const state = { removed: false } @@ -24,16 +23,16 @@ it.effect("legacy provider logout removes every Core credential", () => Effect.gen(function* () { state.removed = false const service = yield* Credential.Service - const integrationID = IntegrationSchema.ID.make("anthropic") + const integrationID = Integration.ID.make("anthropic") yield* service.create({ integrationID, label: "first", - value: new Credential.Key({ type: "key", key: "first" }), + value: Credential.Key.make({ type: "key", key: "first" }), }) yield* service.create({ integrationID, label: "second", - value: new Credential.Key({ type: "key", key: "second" }), + value: Credential.Key.make({ type: "key", key: "second" }), }) yield* remove("anthropic") diff --git a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts index 7da965292be..70cc9dce51b 100644 --- a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts +++ b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts @@ -1,4 +1,5 @@ // regression test for bash permission metadata.command +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" import { Effect, Layer, ManagedRuntime } from "effect" import { ShellTool } from "../../src/tool/shell" @@ -17,12 +18,12 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags" const runtime = ManagedRuntime.make( Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - Config.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Plugin.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(Config.node), RuntimeFlags.layer(), ), ) diff --git a/packages/opencode/test/kilocode/builtin-skills.test.ts b/packages/opencode/test/kilocode/builtin-skills.test.ts index d673225bb3d..85e33a6df6e 100644 --- a/packages/opencode/test/kilocode/builtin-skills.test.ts +++ b/packages/opencode/test/kilocode/builtin-skills.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -8,7 +9,7 @@ import { BUILTIN_SKILLS } from "../../src/kilocode/skills/builtin" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Skill.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Skill.node), AppNodeBuilder.build(CrossSpawnSpawner.node))) it.instance( "built-in skills are present in empty project", diff --git a/packages/opencode/test/kilocode/cloud/command.test.ts b/packages/opencode/test/kilocode/cloud/command.test.ts index 4f18a6ce4b7..f6b9c0f39a9 100644 --- a/packages/opencode/test/kilocode/cloud/command.test.ts +++ b/packages/opencode/test/kilocode/cloud/command.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" @@ -30,7 +31,7 @@ const auth = Layer.mock(Auth.Service)({ ), }) -const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, Git.defaultLayer, auth)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Agent.node), AppNodeBuilder.build(Config.node), AppNodeBuilder.build(Git.node), auth)) const run = Effect.fn("CloudCommandTest.git")(function* (cwd: string, ...args: string[]) { const git = yield* Git.Service diff --git a/packages/opencode/test/kilocode/cloud/defaults.test.ts b/packages/opencode/test/kilocode/cloud/defaults.test.ts index 016705c7e7a..d014e615c47 100644 --- a/packages/opencode/test/kilocode/cloud/defaults.test.ts +++ b/packages/opencode/test/kilocode/cloud/defaults.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import { Effect, Layer, Redacted, Ref } from "effect" import { Agent } from "@/agent/agent" @@ -9,7 +10,7 @@ import { CloudDefaults } from "@/kilocode/cloud/defaults" import { MAX_CLOUD_AGENT_RESPONSE_BYTES } from "@/kilocode/cloud/response-json" import { testEffect } from "../../lib/effect" -const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Agent.node), AppNodeBuilder.build(Config.node))) type RequestInfo = { readonly authorization: string | null diff --git a/packages/opencode/test/kilocode/cloud/repository.test.ts b/packages/opencode/test/kilocode/cloud/repository.test.ts index 0f38f92170c..9b5faf3f077 100644 --- a/packages/opencode/test/kilocode/cloud/repository.test.ts +++ b/packages/opencode/test/kilocode/cloud/repository.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer } from "effect" @@ -6,7 +7,7 @@ import { CloudRepository } from "../../../src/kilocode/cloud/repository" import { tmpdirScoped } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" -const it = testEffect(Layer.mergeAll(Git.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Git.node), AppNodeBuilder.build(CrossSpawnSpawner.node))) const run = Effect.fn("CloudRepositoryTest.git")(function* (cwd: string, ...args: string[]) { const git = yield* Git.Service diff --git a/packages/opencode/test/kilocode/command-timeout.test.ts b/packages/opencode/test/kilocode/command-timeout.test.ts index e242ef60ec8..efa3eef13ee 100644 --- a/packages/opencode/test/kilocode/command-timeout.test.ts +++ b/packages/opencode/test/kilocode/command-timeout.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect, test } from "bun:test" import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import * as Sink from "effect/Sink" @@ -22,13 +23,13 @@ const encoder = new TextEncoder() const it = testEffect(Layer.empty) const shell = testEffect( Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - RuntimeFlags.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Plugin.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Config.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(RuntimeFlags.node), ), ) diff --git a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts index f7f18cff178..20e67cae66b 100644 --- a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts +++ b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts @@ -1,33 +1,28 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterEach, describe, expect, mock, test } from "bun:test" import { APICallError } from "ai" import { Effect, Layer, ManagedRuntime, Scope } from "effect" import * as Stream from "effect/Stream" import { LLMEvent, type LLMEvent as Event } from "@opencode-ai/llm" -import { Database } from "@opencode-ai/core/database/database" -import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Image } from "../../src/image/image" import { KiloCompactionPayloadRecovery } from "../../src/kilocode/session/compaction-payload-recovery" import { KiloSessionCompaction } from "../../src/kilocode/session/compaction" -import { Permission } from "../../src/permission" -import { Plugin } from "../../src/plugin" import { provideTestInstance } from "../fixture/fixture" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { Snapshot } from "../../src/snapshot" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" -import * as SessionProcessorModule from "../../src/session/processor" import { Session as SessionNs } from "../../src/session/session" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionCompaction } from "../../src/session/compaction" -import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { SyncEvent } from "../../src/sync" import { ProviderTest } from "../fake/provider" +import { Provider } from "../../src/provider/provider" import { tmpdir } from "../fixture/fixture" const sessionID = SessionID.make("ses_payload_recovery") @@ -67,6 +62,7 @@ const summary = Layer.succeed( computeDiff: () => Effect.succeed([]), }), ) +const summaryNode = LayerNode.make({ service: SessionSummary.Service, layer: summary, deps: [] }) function base(id: MessageID) { return { @@ -154,32 +150,27 @@ function reply( const scope = Layer.effect(Scope.Scope, Scope.make()) -function runtime(layer: Layer.Layer, config = Config.defaultLayer) { - const bus = Bus.layer - const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), - ) +function runtime(layer: Layer.Layer, config = AppNodeBuilder.build(Config.node)) { const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 100_000, output: 32_000 } }) + const llm = LayerNode.make({ service: LLM.Service, layer, deps: [] }) + const cfg = LayerNode.make({ service: Config.Service, layer: config, deps: [] }) + const provider = LayerNode.make({ + service: Provider.Service, + layer: ProviderTest.fake({ model }).layer, + deps: [], + }) + const flags = LayerNode.make({ service: RuntimeFlags.Service, layer: RuntimeFlags.layer(), deps: [] }) return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( - Layer.provide(ProviderTest.fake({ model }).layer), - Layer.provideMerge(SessionNs.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(layer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(status), - Layer.provide(bus), - Layer.provide(config), - Layer.provide(RuntimeFlags.layer()), + LayerNode.compile(LayerNode.group([SessionCompaction.node, SessionNs.node, SessionProjector.node]), [ + [LLM.node, llm], + [Config.node, cfg], + [Provider.node, provider], + [RuntimeFlags.node, flags], + [SessionSummary.node, summaryNode], + ]).pipe( + Layer.provide(Bus.layer), Layer.provide(scope), Layer.provide(SyncEvent.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Database.defaultLayer), ), ) } @@ -269,132 +260,136 @@ describe("KiloCompactionPayloadRecovery", () => { } }) - test("retries compaction without media and tool outputs after payload-size failure", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - const captures: string[] = [] - stub.push((input) => { - captures.push(JSON.stringify(input.messages)) - return Stream.fail( - new APICallError({ - message: "Request Entity Too Large", - url: "https://api.kilo.ai/api/openrouter/responses", - requestBodyValues: {}, - statusCode: 413, - responseHeaders: { "content-type": "text/plain" }, - responseBody: "Request Entity Too Large\n\nFUNCTION_PAYLOAD_TOO_LARGE", - isRetryable: false, - }), - ) - }) - stub.push( - reply("summary", (input) => { + test( + "retries compaction without media and tool outputs after payload-size failure", + async () => { + await using tmp = await tmpdir({ git: true }) + const stub = llm() + const captures: string[] = [] + stub.push((input) => { captures.push(JSON.stringify(input.messages)) - }), - ) - - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const rt = runtime(stub.layer, Config.defaultLayer) - const svc = service(rt) - const session = await svc.create({}) - const old = await user(svc, session.id, "old image turn") - await svc.updatePart({ - id: PartID.ascending(), - messageID: old.id, - sessionID: session.id, - type: "file", - mime: "image/png", - filename: "old.png", - url: `data:image/png;base64,${"a".repeat(8_000)}`, - }) - const oldReply = await assistant(svc, session.id, old.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: oldReply.id, - sessionID: session.id, - type: "tool", - callID: crypto.randomUUID(), - tool: "bash", - state: { - status: "completed", - input: {}, - output: "old output".repeat(10_000), - title: "old", - metadata: {}, - time: { start: Date.now(), end: Date.now() }, - }, - }) - await user(svc, session.id, "latest turn") - const keep = await user(svc, session.id, "preserved tail turn") - const keepReply = await assistant(svc, session.id, keep.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: keepReply.id, - sessionID: session.id, - type: "tool", - callID: crypto.randomUUID(), - tool: "bash", - state: { - status: "completed", - input: {}, - output: "keep output", - title: "keep", - metadata: {}, - time: { start: Date.now(), end: Date.now() }, - }, - }) - await Effect.runPromise( - KiloSessionCompaction.create({ - session: { - updateMessage: (msg) => Effect.promise(() => svc.updateMessage(msg)), - updatePart: (part) => Effect.promise(() => svc.updatePart(part)), - }, - sessionID: session.id, - agent: "build", - model: ref, - auto: false, + return Stream.fail( + new APICallError({ + message: "Request Entity Too Large", + url: "https://api.kilo.ai/api/openrouter/responses", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "text/plain" }, + responseBody: "Request Entity Too Large\n\nFUNCTION_PAYLOAD_TOO_LARGE", + isRetryable: false, }), ) + }) + stub.push( + reply("summary", (input) => { + captures.push(JSON.stringify(input.messages)) + }), + ) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const rt = runtime(stub.layer, AppNodeBuilder.build(Config.node)) + const svc = service(rt) + const session = await svc.create({}) + const old = await user(svc, session.id, "old image turn") + await svc.updatePart({ + id: PartID.ascending(), + messageID: old.id, + sessionID: session.id, + type: "file", + mime: "image/png", + filename: "old.png", + url: `data:image/png;base64,${"a".repeat(8_000)}`, + }) + const oldReply = await assistant(svc, session.id, old.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: oldReply.id, + sessionID: session.id, + type: "tool", + callID: crypto.randomUUID(), + tool: "bash", + state: { + status: "completed", + input: {}, + output: "old output".repeat(10_000), + title: "old", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + await user(svc, session.id, "latest turn") + const keep = await user(svc, session.id, "preserved tail turn") + const keepReply = await assistant(svc, session.id, keep.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: keepReply.id, + sessionID: session.id, + type: "tool", + callID: crypto.randomUUID(), + tool: "bash", + state: { + status: "completed", + input: {}, + output: "keep output", + title: "keep", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + await Effect.runPromise( + KiloSessionCompaction.create({ + session: { + updateMessage: (msg) => Effect.promise(() => svc.updateMessage(msg)), + updatePart: (part) => Effect.promise(() => svc.updatePart(part)), + }, + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }), ) - expect(result).toBe("continue") - expect(captures).toHaveLength(2) - expect(captures[0]).toContain("Attached image/png: old.png") - expect(captures[0]).toContain("old output") - expect(captures[0]).not.toContain("keep output") - expect(captures[1]).not.toContain("data:image/png;base64") - expect(captures[1]).not.toContain("old output") - expect(captures[1]).not.toContain("keep output") - expect(captures[1]).toContain("Attached image/png: old.png") - expect(captures[1]).toContain("Old tool result content cleared") - const tools = (await svc.messages({ sessionID: session.id })) - .flatMap((msg) => msg.parts) - .filter((part): part is MessageV2.ToolPart => part.type === "tool") - expect(tools).toHaveLength(2) - expect(tools[0]?.type).toBe("tool") - if (tools[0]?.state.status === "completed") expect(tools[0].state.time.compacted).toBeNumber() - expect(tools[1]?.type).toBe("tool") - if (tools[1]?.state.status === "completed") expect(tools[1].state.time.compacted).toBeUndefined() - } finally { - await rt.dispose() - } - }, - }) - }) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(result).toBe("continue") + expect(captures).toHaveLength(2) + expect(captures[0]).toContain("Attached image/png: old.png") + expect(captures[0]).toContain("old output") + expect(captures[0]).not.toContain("keep output") + expect(captures[1]).not.toContain("data:image/png;base64") + expect(captures[1]).not.toContain("old output") + expect(captures[1]).not.toContain("keep output") + expect(captures[1]).toContain("Attached image/png: old.png") + expect(captures[1]).toContain("Old tool result content cleared") + const tools = (await svc.messages({ sessionID: session.id })) + .flatMap((msg) => msg.parts) + .filter((part): part is MessageV2.ToolPart => part.type === "tool") + expect(tools).toHaveLength(2) + expect(tools[0]?.type).toBe("tool") + if (tools[0]?.state.status === "completed") expect(tools[0].state.time.compacted).toBeNumber() + expect(tools[1]?.type).toBe("tool") + if (tools[1]?.state.status === "completed") expect(tools[1].state.time.compacted).toBeUndefined() + } finally { + await rt.dispose() + } + }, + }) + }, + 30_000, + ) }) diff --git a/packages/opencode/test/kilocode/config-gitignore.test.ts b/packages/opencode/test/kilocode/config-gitignore.test.ts index c87cad8404e..5d5a01805d9 100644 --- a/packages/opencode/test/kilocode/config-gitignore.test.ts +++ b/packages/opencode/test/kilocode/config-gitignore.test.ts @@ -6,6 +6,7 @@ // config directory. These must be ignored so they don't appear as untracked // files in the user's project. +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect, test } from "bun:test" import fs from "fs/promises" import path from "path" @@ -24,8 +25,9 @@ import { Filesystem } from "../../src/util/filesystem" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { HttpClient } from "effect/unstable/http" import { tmpdir } from "../fixture/fixture" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" -const infra = CrossSpawnSpawner.defaultLayer.pipe( +const infra = AppNodeBuilder.build(CrossSpawnSpawner.node).pipe( Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), ) @@ -41,24 +43,19 @@ const emptyAuth = Layer.mock(Auth.Service)({ const noopNpm = Layer.mock(Npm.Service)({ install: () => Effect.void, add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), + which: () => Effect.succeed(undefined), }) const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) -const testLayer = Config.layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), - Layer.provide(emptyAccount), - Layer.provideMerge(infra), - Layer.provide(noopNpm), - Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)), -) +const testLayer = AppNodeBuilder.build(Config.node, [ + [Auth.node, emptyAuth], + [Account.node, emptyAccount], + [Npm.node, noopNpm], + [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, unexpectedHttp)], +]).pipe(Layer.provideMerge(infra)) test(".gitignore in .kilo config dir includes pnpm and yarn lockfile patterns", async () => { await using tmp = await tmpdir() diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 0bd9ecccb4c..2a4c8306d73 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" import { Effect, Layer, Option, Schema } from "effect" @@ -21,8 +22,9 @@ import { KilocodeConfig } from "../../../src/kilocode/config/config" import { provideTestInstance } from "../../fixture/fixture" import { Filesystem } from "../../../src/util/filesystem" import { disposeAllInstances, tmpdir } from "../../fixture/fixture" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" -const infra = CrossSpawnSpawner.defaultLayer.pipe( +const infra = AppNodeBuilder.build(CrossSpawnSpawner.node).pipe( Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), ) const emptyAccount = Layer.mock(Account.Service)({ @@ -35,22 +37,17 @@ const emptyAuth = Layer.mock(Auth.Service)({ const noopNpm = Layer.mock(Npm.Service)({ install: () => Effect.void, add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), + which: () => Effect.succeed(undefined), }) const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) -const layer = Config.layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), - Layer.provide(emptyAccount), - Layer.provideMerge(infra), - Layer.provide(noopNpm), - Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)), -) +const layer = AppNodeBuilder.build(Config.node, [ + [Auth.node, emptyAuth], + [Account.node, emptyAccount], + [Npm.node, noopNpm], + [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, unexpectedHttp)], +]).pipe(Layer.provideMerge(infra)) const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) const clear = () => diff --git a/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts index db8bc0b9cd5..2c01821116c 100644 --- a/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts +++ b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect, test } from "bun:test" import { Effect, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" @@ -21,8 +22,9 @@ import { provideTestInstance } from "../../fixture/fixture" import { Npm } from "@opencode-ai/core/npm" import { HttpClient } from "effect/unstable/http" import { disposeAllInstances, tmpdir } from "../../fixture/fixture" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" -const infra = CrossSpawnSpawner.defaultLayer.pipe( +const infra = AppNodeBuilder.build(CrossSpawnSpawner.node).pipe( Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), ) const emptyAccount = Layer.mock(Account.Service)({ @@ -35,22 +37,17 @@ const emptyAuth = Layer.mock(Auth.Service)({ const noopNpm = Layer.mock(Npm.Service)({ install: () => Effect.void, add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), + which: () => Effect.succeed(undefined), }) const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) -const layer = Config.layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), - Layer.provide(emptyAccount), - Layer.provideMerge(infra), - Layer.provide(noopNpm), - Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)), -) +const layer = AppNodeBuilder.build(Config.node, [ + [Auth.node, emptyAuth], + [Account.node, emptyAccount], + [Npm.node, noopNpm], + [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, unexpectedHttp)], +]).pipe(Layer.provideMerge(infra)) const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) describe("kilocode default indexing plugin", () => { diff --git a/packages/opencode/test/kilocode/cost-propagation.test.ts b/packages/opencode/test/kilocode/cost-propagation.test.ts index 10063613963..5fe70b24750 100644 --- a/packages/opencode/test/kilocode/cost-propagation.test.ts +++ b/packages/opencode/test/kilocode/cost-propagation.test.ts @@ -2,14 +2,15 @@ // the same parent assistant message. Without the internal lock, parallel // subagent completions race on read-modify-write and lose deltas (#6321). +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Bus } from "../../src/bus" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { KiloCostPropagation } from "../../src/kilocode/session/cost-propagation" -import { Instance } from "../../src/kilocode/instance" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Session } from "../../src/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID } from "../../src/session/schema" @@ -30,7 +31,16 @@ const ref = { } const it = testEffect( - Layer.mergeAll(Session.defaultLayer, Bus.layer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer), + LayerNode.compile( + LayerNode.group([ + Session.node, + SessionProjector.node, + MessageV2.node, + Bus.node, + Database.node, + CrossSpawnSpawner.node, + ]), + ), ) const seed = Effect.fn("CostPropagationTest.seed")(function* () { diff --git a/packages/opencode/test/kilocode/database/sqlite-error.test.ts b/packages/opencode/test/kilocode/database/sqlite-error.test.ts new file mode 100644 index 00000000000..7a3ea7472e1 --- /dev/null +++ b/packages/opencode/test/kilocode/database/sqlite-error.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { LockTimeoutError, SqlError, UnknownError } from "effect/unstable/sql/SqlError" +import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" + +describe("SQLite errors", () => { + test("recognizes lock timeouts as busy database errors", () => { + const error = new SqlError({ + reason: new LockTimeoutError({ + cause: new Error("database is locked"), + message: "Failed to execute statement", + operation: "execute", + }), + }) + + expect(isBusy(error)).toBe(true) + expect(busyMessage).toBe("Database is busy. Please try again in a moment.") + }) + + test("does not classify other SQLite errors as busy", () => { + const error = new SqlError({ + reason: new UnknownError({ + cause: new Error("constraint failed"), + message: "Failed to execute statement", + operation: "execute", + }), + }) + + expect(isBusy(error)).toBe(false) + }) +}) diff --git a/packages/opencode/test/kilocode/diff-full.test.ts b/packages/opencode/test/kilocode/diff-full.test.ts index 3f67cee9237..d07972db653 100644 --- a/packages/opencode/test/kilocode/diff-full.test.ts +++ b/packages/opencode/test/kilocode/diff-full.test.ts @@ -2,6 +2,7 @@ // // Tests for the git-based diff generator that replaced the JS Myers path. +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { $ } from "bun" import { describe, expect } from "bun:test" import { parsePatch } from "diff" @@ -48,7 +49,7 @@ const commit = async (dir: string, message: string) => { return head.stdout.toString().trim() } -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node))) describe("DiffFull.batch", () => { it.live("produces one patch per modified file", () => diff --git a/packages/opencode/test/kilocode/edit-permission-filediff.test.ts b/packages/opencode/test/kilocode/edit-permission-filediff.test.ts index 4fd63819227..b7638805520 100644 --- a/packages/opencode/test/kilocode/edit-permission-filediff.test.ts +++ b/packages/opencode/test/kilocode/edit-permission-filediff.test.ts @@ -3,6 +3,7 @@ // permission-ask metadata. Without `filediff`, the VS Code extension's // PermissionDock cannot render the inline diff preview. +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterAll, afterEach, describe, test, expect } from "bun:test" import path from "path" import { Effect, Layer, ManagedRuntime } from "effect" @@ -20,13 +21,13 @@ import { SessionID, MessageID } from "../../src/session/schema" const runtime = ManagedRuntime.make( Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - Format.defaultLayer, + AppNodeBuilder.build(LSP.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Format.node), Bus.layer, - Truncate.defaultLayer, - Agent.defaultLayer, - EventV2Bridge.defaultLayer, + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(EventV2Bridge.node), ), ) diff --git a/packages/opencode/test/kilocode/fixture/stall-transport.ts b/packages/opencode/test/kilocode/fixture/stall-transport.ts index 8f9c145c169..7e375020dfb 100644 --- a/packages/opencode/test/kilocode/fixture/stall-transport.ts +++ b/packages/opencode/test/kilocode/fixture/stall-transport.ts @@ -16,6 +16,8 @@ // Progress is mirrored to a JSON file so tests can assert what the provider saw // without sharing module state with the plugin that loads this file. +import { rename } from "node:fs/promises" + export type StallState = { calls: number; stalls: number; recovered: number } const HEAD = { id: "chatcmpl-stall", object: "chat.completion.chunk", created: 0, model: "mock-model" } @@ -84,7 +86,16 @@ function stalling() { export function createStallTransport(input: { state: string; answer?: string; command?: string }) { const state: StallState = { calls: 0, stalls: 0, recovered: 0 } - const save = () => Bun.write(input.state, JSON.stringify(state)) + let pending = Promise.resolve() + const save = () => { + const json = JSON.stringify(state) + const tmp = `${input.state}.${crypto.randomUUID()}.tmp` + pending = pending.then(async () => { + await Bun.write(tmp, json) + await rename(tmp, input.state) + }) + return pending + } return async (_input: unknown, init?: { body?: unknown }) => { const body = typeof init?.body === "string" ? init.body : "" diff --git a/packages/opencode/test/kilocode/installation/upgrade.test.ts b/packages/opencode/test/kilocode/installation/upgrade.test.ts index 8a55ac357a6..07b2f2ae58e 100644 --- a/packages/opencode/test/kilocode/installation/upgrade.test.ts +++ b/packages/opencode/test/kilocode/installation/upgrade.test.ts @@ -6,6 +6,9 @@ import { InstallationChannel } from "@opencode-ai/core/installation/version" import { AppProcess } from "@opencode-ai/core/process" import { Installation } from "../../../src/installation" import { testEffect } from "../../lib/effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" const encoder = new TextEncoder() @@ -48,8 +51,10 @@ function layer( handler: (cmd: string, args: readonly string[]) => string, request?: (request: HttpClientRequest.HttpClientRequest) => Response, ) { - const proc = AppProcess.layer.pipe(Layer.provide(spawner(handler))) - return Installation.layer.pipe(Layer.provide(http(request)), Layer.provide(proc)) + return AppNodeBuilder.build(Installation.node, [ + [LayerNodePlatform.httpClient, http(request)], + [CrossSpawnSpawner.node, spawner(handler)], + ]) } describe("Kilo installation upgrade", () => { diff --git a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts index 995555f368c..cf8f7b5386b 100644 --- a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts +++ b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts @@ -1,15 +1,20 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterAll, beforeAll, describe, expect, test } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Deferred, Effect, Fiber, Layer } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" -import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { KilocodeWatcher } from "../../src/kilocode/watcher" import { tmpdirScoped } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" -const layer = Layer.mergeAll(InstanceLayer.layer, Git.defaultLayer, CrossSpawnSpawner.defaultLayer) +const layer = Layer.mergeAll( + AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), + AppNodeBuilder.build(Git.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), +) const it = testEffect(layer) // The suite disables the file watcher (see test/preload.ts); this file tests it, so opt back in. @@ -29,6 +34,10 @@ describe("KilocodeWatcher.eager", () => { expect(KilocodeWatcher.eager("vscode")).toBe(false) }) + test("skips eager location watchers for JetBrains", () => { + expect(KilocodeWatcher.eager("jetbrains")).toBe(false) + }) + test("keeps eager location watchers for the standalone CLI", () => { expect(KilocodeWatcher.eager("cli")).toBe(true) expect(KilocodeWatcher.eager(undefined)).toBe(true) diff --git a/packages/opencode/test/kilocode/instruction.test.ts b/packages/opencode/test/kilocode/instruction.test.ts index a30feb2a836..d02058aef4f 100644 --- a/packages/opencode/test/kilocode/instruction.test.ts +++ b/packages/opencode/test/kilocode/instruction.test.ts @@ -1,4 +1,5 @@ // kilocode_change - new file +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import path from "path" import { Effect, FileSystem, Layer } from "effect" @@ -12,20 +13,19 @@ import { Global } from "@opencode-ai/core/global" import { TestConfig } from "../fixture/config" import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Config } from "../../src/config/config" const it = testEffect( - Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, RuntimeFlags.layer(), testInstanceStoreLayer), + Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), NodeFileSystem.layer, RuntimeFlags.layer(), testInstanceStoreLayer), ) const configLayer = TestConfig.layer() const instructionLayer = (global: Partial) => - Instruction.layer.pipe( - Layer.provide(configLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Global.layerWith(global)), - ) + AppNodeBuilder.build(Instruction.node, [ + [Config.node, configLayer], + [Global.node, Global.layerWith(global)], + ]) const provideInstruction = (global: Partial) => diff --git a/packages/opencode/test/kilocode/interactive-terminal.test.ts b/packages/opencode/test/kilocode/interactive-terminal.test.ts index d4f584655e7..03c62fd71f3 100644 --- a/packages/opencode/test/kilocode/interactive-terminal.test.ts +++ b/packages/opencode/test/kilocode/interactive-terminal.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Bus } from "@/bus" import { Agent } from "@/agent/agent" import { Config } from "@/config/config" @@ -21,13 +22,13 @@ import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { it, testEffect } from "../lib/effect" const toolLayer = Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - RuntimeFlags.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Plugin.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Config.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(RuntimeFlags.node), ) const toolIt = testEffect(toolLayer) diff --git a/packages/opencode/test/kilocode/kilo-sessions.test.ts b/packages/opencode/test/kilocode/kilo-sessions.test.ts index b18c95f2a9b..dd973dd9653 100644 --- a/packages/opencode/test/kilocode/kilo-sessions.test.ts +++ b/packages/opencode/test/kilocode/kilo-sessions.test.ts @@ -1,4 +1,5 @@ // kilocode_change - new file +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" import { tmpdir } from "../fixture/fixture" import { Effect, Layer } from "effect" @@ -23,17 +24,17 @@ import { InstanceStore } from "../../src/project/instance-store" import { TestInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { RemoteProtocol } from "../../src/kilo-sessions/remote-protocol" -const it = testEffect(CrossSpawnSpawner.defaultLayer) -const multi = testEffect(Layer.merge(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer)) +const it = testEffect(AppNodeBuilder.build(CrossSpawnSpawner.node)) +const multi = testEffect(Layer.merge(AppNodeBuilder.build(CrossSpawnSpawner.node), testInstanceStoreLayer)) function layer(overrides: Partial = {}) { return Layer.merge( KiloSessions.layer.pipe( Layer.provideMerge(Bus.layer), Layer.provide(TestConfig.layer(overrides)), - Layer.provide(Session.defaultLayer), + Layer.provide(AppNodeBuilder.build(Session.node)), ), - Auth.defaultLayer, + AppNodeBuilder.build(Auth.node), ) } diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts index 201adcb7d3c..cb6767a1e7f 100644 --- a/packages/opencode/test/kilocode/local-model.test.ts +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -77,6 +77,7 @@ const realToast = await import("@tui/ui/toast") const realEvent = await import("@tui/context/event") const realRoute = await import("@tui/context/route") const realRuntime = await import("@tui/context/runtime") +const realPermission = await import("@tui/context/permission") let capturedInit: (() => any) | undefined @@ -169,6 +170,11 @@ mock.module("@tui/context/route", () => ({ useRoute: () => ({ data: { type: "home" }, navigate: () => {} }), })) +mock.module("@tui/context/permission", () => ({ + ...realPermission, + usePermission: () => ({ mode: "normal", set: () => {}, toggle: () => {} }), +})) + // Import the real Global to get the state path (set by test preload via XDG_STATE_HOME) const { Global } = await import("@opencode-ai/core/global") const modelJsonPath = path.join(Global.Path.state, "model.json") diff --git a/packages/opencode/test/kilocode/non-git-global-skills.test.ts b/packages/opencode/test/kilocode/non-git-global-skills.test.ts index a598b65ff2c..b19ebbaf850 100644 --- a/packages/opencode/test/kilocode/non-git-global-skills.test.ts +++ b/packages/opencode/test/kilocode/non-git-global-skills.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import path from "path" @@ -15,17 +16,12 @@ import { provideInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixt import { testEffect } from "../lib/effect" const skills = (home: string) => - Skill.layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layerWith({ home })), - Layer.provide(RuntimeFlags.layer({ disableExternalSkills: false, disableClaudeCodeSkills: false })), - ) + AppNodeBuilder.build(Skill.node, [ + [Global.node, Global.layerWith({ home })], + [RuntimeFlags.node, RuntimeFlags.layer({ disableExternalSkills: false, disableClaudeCodeSkills: false })], + ]) -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), testInstanceStoreLayer)) describe("non-Git global skills", () => { it.live("loads global skills when the project is below the home directory", () => diff --git a/packages/opencode/test/kilocode/notebook-tools.test.ts b/packages/opencode/test/kilocode/notebook-tools.test.ts index 7bd89e3103b..4b9cd5b890c 100644 --- a/packages/opencode/test/kilocode/notebook-tools.test.ts +++ b/packages/opencode/test/kilocode/notebook-tools.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" import { Agent } from "@/agent/agent" import { Notebook } from "@/kilocode/notebook/service" @@ -42,7 +43,7 @@ const notebook = Layer.mock(Notebook.Service, { }) }, }) -const it = testEffect(Layer.mergeAll(notebook, Agent.defaultLayer, Truncate.defaultLayer)) +const it = testEffect(Layer.mergeAll(notebook, AppNodeBuilder.build(Agent.node), AppNodeBuilder.build(Truncate.node))) function context(asks: Parameters[0][]): Tool.Context { return { diff --git a/packages/opencode/test/kilocode/nvidia-headers.test.ts b/packages/opencode/test/kilocode/nvidia-headers.test.ts index 62bca2387e2..8fa3169813d 100644 --- a/packages/opencode/test/kilocode/nvidia-headers.test.ts +++ b/packages/opencode/test/kilocode/nvidia-headers.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import path from "path" import { Effect, Layer } from "effect" @@ -7,7 +8,7 @@ import { testEffect } from "../lib/effect" import { Env } from "../../src/env" import { Provider } from "../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" -const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Provider.node), AppNodeBuilder.build(Env.node), AppNodeBuilder.build(CrossSpawnSpawner.node))) function withNvidiaKey(self: Effect.Effect) { return Effect.gen(function* () { diff --git a/packages/opencode/test/kilocode/oauth-branding.test.ts b/packages/opencode/test/kilocode/oauth-branding.test.ts index 098e07592d1..236a1bcfe20 100644 --- a/packages/opencode/test/kilocode/oauth-branding.test.ts +++ b/packages/opencode/test/kilocode/oauth-branding.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { KiloOauthCallbackPage } from "@opencode-ai/core/kilocode/oauth/page" const root = path.join(__dirname, "..", "..") @@ -14,14 +15,24 @@ describe("Kilo OAuth branding", () => { expect(src).not.toContain("return to OpenCode") }) - test("extracted core OAuth browser flow uses Kilo branding", async () => { - const src = await Bun.file(path.join(root, "..", "core", "src", "plugin", "provider", "openai-auth.ts")).text() + test("core OAuth browser flow uses Kilo branding", async () => { + const src = await Bun.file(path.join(root, "..", "core", "src", "plugin", "provider", "openai.ts")).text() + const pages = [ + KiloOauthCallbackPage.success({ provider: "ChatGPT" }), + KiloOauthCallbackPage.error("Denied", { provider: "ChatGPT" }), + ] expect(src).toContain('originator: "kilo"') expect(src).toContain('"User-Agent": `kilo/${InstallationVersion}`') - expect(src).toContain("Kilo") + expect(src).toContain("KiloOauthCallbackPage") expect(src).not.toContain('originator: "opencode"') - expect(src).not.toContain("OpenCode") + for (const page of pages) { + expect(page).toContain("· Kilo") + expect(page).toContain('aria-label="Kilo Code"') + expect(page).toContain('viewBox="0 0 100 100"') + expect(page).not.toContain("OpenCode") + expect(page).not.toContain('viewBox="0 0 234 42"') + } }) test("MCP OAuth callback page uses Kilo branding", async () => { diff --git a/packages/opencode/test/kilocode/patch.test.ts b/packages/opencode/test/kilocode/patch.test.ts index db8d0cbc5bc..edd79af5aac 100644 --- a/packages/opencode/test/kilocode/patch.test.ts +++ b/packages/opencode/test/kilocode/patch.test.ts @@ -6,6 +6,7 @@ // These round-trip through the apply_patch tool so the Kilo encoding layer is // exercised with the upstream patch parser. +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, beforeEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" @@ -25,14 +26,14 @@ import { provideInstance, testInstanceStoreLayer } from "../fixture/fixture" import { FSUtil } from "@opencode-ai/core/fs-util" const layer = Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(FSUtil.node), Bus.layer, - Format.defaultLayer, - LSP.defaultLayer, - Truncate.defaultLayer, + AppNodeBuilder.build(Format.node), + AppNodeBuilder.build(LSP.node), + AppNodeBuilder.build(Truncate.node), testInstanceStoreLayer, - EventV2Bridge.defaultLayer, + AppNodeBuilder.build(EventV2Bridge.node), ) const apply = (dir: string, patchText: string) => diff --git a/packages/opencode/test/kilocode/permission/deny-provenance.test.ts b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts index 67613e954b5..a0d7029494b 100644 --- a/packages/opencode/test/kilocode/permission/deny-provenance.test.ts +++ b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts @@ -1,25 +1,18 @@ import { describe, expect, test } from "bun:test" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Cause, Effect, Exit, Layer } from "effect" import { Bus } from "../../../src/bus" import { Permission } from "../../../src/permission" import { PermissionProvenance } from "../../../src/kilocode/permission/provenance" -import { EventV2Bridge } from "../../../src/event-v2-bridge" -import { Database } from "@opencode-ai/core/database/database" import { SessionID } from "../../../src/session/schema" -import * as Config from "../../../src/config/config" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" const env = Layer.mergeAll( - Permission.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - ), - Config.defaultLayer, + AppNodeBuilder.build(Permission.node), Bus.layer, - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), ) const it = testEffect(env) diff --git a/packages/opencode/test/kilocode/permission/env-read.test.ts b/packages/opencode/test/kilocode/permission/env-read.test.ts index d157275b876..366fd5408e0 100644 --- a/packages/opencode/test/kilocode/permission/env-read.test.ts +++ b/packages/opencode/test/kilocode/permission/env-read.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterAll, describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" @@ -17,13 +18,9 @@ import { testEffect } from "../../lib/effect" const bus = Bus.layer const env = Layer.mergeAll( - Permission.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - ), + AppNodeBuilder.build(Permission.node), bus, - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), ) const it = testEffect(env) @@ -33,7 +30,7 @@ afterAll(async () => { await fs.rm(path.join(dir, file), { force: true }).catch(() => {}) } await Effect.runPromise( - Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)), + Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(Config.node))), ) await InstanceRuntime.disposeAllInstances() }) diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts index 5d395c9c315..99f5c98c7a9 100644 --- a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect, test } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer, ManagedRuntime } from "effect" import path from "path" @@ -25,13 +26,13 @@ import { KilocodePaths } from "../../../src/kilocode/paths" const runtime = ManagedRuntime.make( Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Config.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Config.node), RuntimeFlags.layer(), - Plugin.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, + AppNodeBuilder.build(Plugin.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Agent.node), ), ) @@ -74,13 +75,9 @@ const configFile = path.join(config, "hello.txt") const configGlob = glob(path.join(config, "*")) const bus = Bus.layer const env = Layer.mergeAll( - Permission.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - ), + AppNodeBuilder.build(Permission.node), bus, - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), ) const it = testEffect(env) diff --git a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts index 50bd779a94e..16b27ae46f3 100644 --- a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts +++ b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect, describe, afterAll } from "bun:test" import fs from "fs/promises" import path from "path" @@ -17,14 +18,10 @@ import { testEffect } from "../../lib/effect" const bus = Bus.layer const env = Layer.mergeAll( - Permission.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - ), - Config.defaultLayer, + AppNodeBuilder.build(Permission.node), + AppNodeBuilder.build(Config.node), bus, - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), ) const it = testEffect(env) @@ -34,7 +31,7 @@ afterAll(async () => { await fs.rm(path.join(dir, file), { force: true }).catch(() => {}) } await Effect.runPromise( - Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)), + Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(Config.node))), ) await InstanceRuntime.disposeAllInstances() }) diff --git a/packages/opencode/test/kilocode/permission/next.reply-routing.test.ts b/packages/opencode/test/kilocode/permission/next.reply-routing.test.ts index 7a2dcba551f..686efa2dcf5 100644 --- a/packages/opencode/test/kilocode/permission/next.reply-routing.test.ts +++ b/packages/opencode/test/kilocode/permission/next.reply-routing.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect, describe, afterAll } from "bun:test" import fs from "fs/promises" import path from "path" @@ -17,13 +18,9 @@ import { testEffect } from "../../lib/effect" const bus = Bus.layer const env = Layer.mergeAll( - Permission.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - ), + AppNodeBuilder.build(Permission.node), bus, - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), testInstanceStoreLayer, ) const it = testEffect(env) @@ -34,7 +31,7 @@ afterAll(async () => { await fs.rm(path.join(dir, file), { force: true }).catch(() => {}) } await Effect.runPromise( - Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)), + Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(Config.node))), ) await InstanceRuntime.disposeAllInstances() }) diff --git a/packages/opencode/test/kilocode/permission/permission-origins.test.ts b/packages/opencode/test/kilocode/permission/permission-origins.test.ts index 8899de5bc1a..8d36a30f33d 100644 --- a/packages/opencode/test/kilocode/permission/permission-origins.test.ts +++ b/packages/opencode/test/kilocode/permission/permission-origins.test.ts @@ -2,6 +2,7 @@ // Verifies that Config.permission_origins attributes each permission key to the scope // (global XDG vs local project) that last set it, which drives auto-approval provenance. +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect, test } from "bun:test" import fs from "fs/promises" import path from "path" @@ -19,8 +20,9 @@ import { provideTestInstance } from "../../fixture/fixture" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { HttpClient } from "effect/unstable/http" import { tmpdir } from "../../fixture/fixture" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" -const infra = CrossSpawnSpawner.defaultLayer.pipe( +const infra = AppNodeBuilder.build(CrossSpawnSpawner.node).pipe( Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), ) const emptyAccount = Layer.mock(Account.Service)({ @@ -31,20 +33,15 @@ const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}) }) const noopNpm = Layer.mock(Npm.Service)({ install: () => Effect.void, add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), + which: () => Effect.succeed(undefined), }) const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`)) -const testLayer = Config.layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), - Layer.provide(emptyAccount), - Layer.provideMerge(infra), - Layer.provide(noopNpm), - Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)), -) +const testLayer = AppNodeBuilder.build(Config.node, [ + [Auth.node, emptyAuth], + [Account.node, emptyAccount], + [Npm.node, noopNpm], + [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, unexpectedHttp)], +]).pipe(Layer.provideMerge(infra)) test("project config permission keys are attributed to the local scope", async () => { await using tmp = await tmpdir() diff --git a/packages/opencode/test/kilocode/permission/skill-shell.test.ts b/packages/opencode/test/kilocode/permission/skill-shell.test.ts index 9f10a53349a..f4aa00bb5c2 100644 --- a/packages/opencode/test/kilocode/permission/skill-shell.test.ts +++ b/packages/opencode/test/kilocode/permission/skill-shell.test.ts @@ -1,30 +1,23 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" -import { EventV2Bridge } from "@/event-v2-bridge" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Database } from "@opencode-ai/core/database/database" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "@/permission" -import { InstanceBootstrap } from "@/project/bootstrap-service" -import { InstanceStore } from "@/project/instance-store" import { testEffect } from "../../lib/effect" import { SessionID } from "@/session/schema" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { Config } from "@/config/config" +import * as Config from "@/config/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // skillShell forces a single up-front prompt over soft allow/deny/auto-approve // rules, but must never override a hard (plan-mode) veto, and must never be // auto-resolved while pending. -const events = EventV2Bridge.defaultLayer -const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const env = Layer.mergeAll( - Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(events)), - events, - CrossSpawnSpawner.defaultLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), -).pipe(Layer.provide(RuntimeFlags.layer()), Layer.provide(Config.defaultLayer)) -const it = testEffect(Layer.mergeAll(env, RuntimeFlags.layer())) + AppNodeBuilder.build(Permission.node), + AppNodeBuilder.build(Config.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), +) +const it = testEffect(env) const ask = (input: Parameters[0]) => Effect.gen(function* () { diff --git a/packages/opencode/test/kilocode/plan-exit-detection.test.ts b/packages/opencode/test/kilocode/plan-exit-detection.test.ts index 281556f858d..a200147a80a 100644 --- a/packages/opencode/test/kilocode/plan-exit-detection.test.ts +++ b/packages/opencode/test/kilocode/plan-exit-detection.test.ts @@ -7,6 +7,8 @@ import { Identifier } from "../../src/id/id" import { SessionID, MessageID, PartID } from "../../src/session/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Instance } from "../../src/kilocode/instance" import { provideTestInstance } from "../fixture/fixture" import { PlanFollowup } from "../../src/kilocode/plan-followup" @@ -21,7 +23,10 @@ import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) -const session = makeRuntime(Session.Service, Session.defaultLayer) +const session = makeRuntime( + Session.Service, + LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])), +) const sessions = { create: (input?: Parameters[0]) => session.runPromise((svc) => svc.create(input)), diff --git a/packages/opencode/test/kilocode/plan-file.test.ts b/packages/opencode/test/kilocode/plan-file.test.ts index fec31c7d8bd..b0719e9754d 100644 --- a/packages/opencode/test/kilocode/plan-file.test.ts +++ b/packages/opencode/test/kilocode/plan-file.test.ts @@ -1,5 +1,6 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect, test } from "bun:test" -import { Effect, Layer, ManagedRuntime } from "effect" +import { Effect, ManagedRuntime } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" import { PlanFile } from "../../src/kilocode/plan-file" @@ -9,12 +10,15 @@ import { Session } from "../../src/session/session" import { MessageID, PartID } from "../../src/session/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { PlanExitTool } from "../../src/tool/plan" import { Tool } from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" import { tmpdir } from "../fixture/fixture" -const rt = ManagedRuntime.make(Layer.mergeAll(Agent.defaultLayer, Session.defaultLayer, Truncate.defaultLayer)) +const rt = ManagedRuntime.make( + LayerNode.compile(LayerNode.group([Agent.node, Session.node, SessionProjector.node, Truncate.node])), +) async function init() { return rt.runPromise( diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 61c804b5864..154956042e0 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -11,6 +11,8 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { EventV2 } from "@opencode-ai/core/event" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { formatTodos, generateHandover, PlanFollowup, PlanFollowupRuntime } from "../../src/kilocode/plan-followup" import { Instance } from "../../src/kilocode/instance" import * as KiloInstance from "../../src/kilocode/instance" @@ -67,7 +69,10 @@ const todo = { }, } -const session = makeRuntime(Session.Service, Session.defaultLayer) +const session = makeRuntime( + Session.Service, + LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])), +) const store = { create: (input?: Parameters[0]) => session.runPromise((svc) => svc.create(input)), get: (id: SessionID) => session.runPromise((svc) => svc.get(id)), diff --git a/packages/opencode/test/kilocode/primary-worktree.test.ts b/packages/opencode/test/kilocode/primary-worktree.test.ts index 1447b71c881..656baaf7d75 100644 --- a/packages/opencode/test/kilocode/primary-worktree.test.ts +++ b/packages/opencode/test/kilocode/primary-worktree.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { $ } from "bun" import { describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -8,7 +9,7 @@ import { primaryPaths, primaryWorktree } from "../../src/kilocode/primary-worktr import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Git.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Git.node), AppNodeBuilder.build(CrossSpawnSpawner.node))) describe("primaryWorktree", () => { it.live("returns the current checkout for a normal repository", () => diff --git a/packages/opencode/test/kilocode/project-config-update.test.ts b/packages/opencode/test/kilocode/project-config-update.test.ts index 2d33ec1a334..f2fcfcdb298 100644 --- a/packages/opencode/test/kilocode/project-config-update.test.ts +++ b/packages/opencode/test/kilocode/project-config-update.test.ts @@ -1,5 +1,6 @@ // kilocode_change - new file +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect, test } from "bun:test" import fs from "fs/promises" import path from "path" @@ -18,8 +19,9 @@ import { Filesystem } from "../../src/util/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { HttpClient } from "effect/unstable/http" import { tmpdir } from "../fixture/fixture" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" -const infra = CrossSpawnSpawner.defaultLayer.pipe( +const infra = AppNodeBuilder.build(CrossSpawnSpawner.node).pipe( Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), ) @@ -35,24 +37,19 @@ const emptyAuth = Layer.mock(Auth.Service)({ const noopNpm = Layer.mock(Npm.Service)({ install: () => Effect.void, add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), + which: () => Effect.succeed(undefined), }) const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) -const layer = Config.layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), - Layer.provide(emptyAccount), - Layer.provideMerge(infra), - Layer.provide(noopNpm), - Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)), -) +const layer = AppNodeBuilder.build(Config.node, [ + [Auth.node, emptyAuth], + [Account.node, emptyAccount], + [Npm.node, noopNpm], + [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, unexpectedHttp)], +]).pipe(Layer.provideMerge(infra)) const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) const save = (config: Config.Info) => diff --git a/packages/opencode/test/kilocode/project/instance-store-logger.test.ts b/packages/opencode/test/kilocode/project/instance-store-logger.test.ts new file mode 100644 index 00000000000..d45f932d1d1 --- /dev/null +++ b/packages/opencode/test/kilocode/project/instance-store-logger.test.ts @@ -0,0 +1,41 @@ +import { expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer, Logger } from "effect" +import { InstanceBootstrap } from "../../../src/project/bootstrap-service" +import { InstanceStore } from "../../../src/project/instance-store" +import { tmpdirScoped } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +let run: Effect.Effect = Effect.void +const bootstrap = Layer.succeed( + InstanceBootstrap.Service, + InstanceBootstrap.Service.of({ run: Effect.suspend(() => run) }), +) +const logger = Logger.make(() => {}) +const layer = LayerNode.compile(LayerNode.group([InstanceStore.node, CrossSpawnSpawner.node]), [ + [InstanceStore.bootstrapNode, bootstrap], +]).pipe(Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false }))) +const it = testEffect(layer) + +it.live("preserves the active logger during instance bootstrap", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const outer = yield* Effect.service(Logger.CurrentLoggers) + let inner: ReadonlySet> | undefined + + run = Effect.gen(function* () { + inner = yield* Effect.service(Logger.CurrentLoggers) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + run = Effect.void + }), + ) + + yield* store.load({ directory: dir }) + + expect(inner).toEqual(outer) + }), +) diff --git a/packages/opencode/test/kilocode/project/instance-store.test.ts b/packages/opencode/test/kilocode/project/instance-store.test.ts index 9a48ff552a1..4528010d409 100644 --- a/packages/opencode/test/kilocode/project/instance-store.test.ts +++ b/packages/opencode/test/kilocode/project/instance-store.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" @@ -10,7 +11,10 @@ import { awaitWithTimeout, testEffect } from "../../lib/effect" const bootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const it = testEffect( - Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(bootstrap)), + Layer.mergeAll( + AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, bootstrap]]), + AppNodeBuilder.build(CrossSpawnSpawner.node), + ), ) const register = (disposer: (directory: string) => Promise) => diff --git a/packages/opencode/test/kilocode/provider-model-refresh.test.ts b/packages/opencode/test/kilocode/provider-model-refresh.test.ts index 1e7a2aefba0..d439a245f18 100644 --- a/packages/opencode/test/kilocode/provider-model-refresh.test.ts +++ b/packages/opencode/test/kilocode/provider-model-refresh.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import { Effect, Layer } from "effect" import { mkdir, rm, utimes, writeFile } from "fs/promises" @@ -91,7 +92,7 @@ it.instance( const after = yield* provider.list() expect(after[ProviderV2.ID.make("acme")]?.models["acme-2"]).toBeDefined() - }).pipe(Effect.provide(Layer.merge(ModelsDev.defaultLayer, Provider.defaultLayer))), + }).pipe(Effect.provide(Layer.merge(AppNodeBuilder.build(ModelsDev.node), AppNodeBuilder.build(Provider.node)))), () => Effect.promise(async () => { Flag.KILO_MODELS_URL = flags.source diff --git a/packages/opencode/test/kilocode/provider-saved-auth.test.ts b/packages/opencode/test/kilocode/provider-saved-auth.test.ts index 6391d69cc24..069a3762ce7 100644 --- a/packages/opencode/test/kilocode/provider-saved-auth.test.ts +++ b/packages/opencode/test/kilocode/provider-saved-auth.test.ts @@ -1,10 +1,11 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import { Effect } from "effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { Provider } from "../../src/provider/provider" import { testEffect } from "../lib/effect" -const it = testEffect(Provider.defaultLayer) +const it = testEffect(AppNodeBuilder.build(Provider.node)) const auth = (value: Record, effect: Effect.Effect) => Effect.acquireUseRelease( diff --git a/packages/opencode/test/kilocode/question-cancel.test.ts b/packages/opencode/test/kilocode/question-cancel.test.ts index 59c83113723..00a5bb6a051 100644 --- a/packages/opencode/test/kilocode/question-cancel.test.ts +++ b/packages/opencode/test/kilocode/question-cancel.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, expect } from "bun:test" import { Effect, Fiber, Layer, Queue } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -8,9 +9,9 @@ import { SessionID } from "../../src/session/schema" import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const events = EventV2Bridge.defaultLayer +const events = AppNodeBuilder.build(EventV2Bridge.node) const it = testEffect( - Layer.mergeAll(Question.layer.pipe(Layer.provide(events)), events, CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(Question.layer.pipe(Layer.provide(events)), events, AppNodeBuilder.build(CrossSpawnSpawner.node)), ) afterEach(async () => { diff --git a/packages/opencode/test/kilocode/question-dismiss-all.test.ts b/packages/opencode/test/kilocode/question-dismiss-all.test.ts index da978a86839..a36c896e684 100644 --- a/packages/opencode/test/kilocode/question-dismiss-all.test.ts +++ b/packages/opencode/test/kilocode/question-dismiss-all.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -6,7 +7,7 @@ import { Question } from "../../src/question" import { MessageID, SessionID } from "../../src/session/schema" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Question.node), AppNodeBuilder.build(CrossSpawnSpawner.node))) const prompt = [ { diff --git a/packages/opencode/test/kilocode/question-option-schema.test.ts b/packages/opencode/test/kilocode/question-option-schema.test.ts index 939dec436b4..d13ead285be 100644 --- a/packages/opencode/test/kilocode/question-option-schema.test.ts +++ b/packages/opencode/test/kilocode/question-option-schema.test.ts @@ -25,7 +25,7 @@ import fs from "node:fs" import path from "node:path" import { Option } from "../../src/question" -const SOURCE = path.resolve(import.meta.dir, "../../src/question/index.ts") +const SOURCE = path.resolve(import.meta.dir, "../../../schema/src/v1/question.ts") describe("QuestionOption schema — Kilo-specific field contract", () => { test("Option class accepts and round-trips the mode field", () => { @@ -56,14 +56,14 @@ describe("QuestionOption schema — Kilo-specific field contract", () => { // resolution that drops the fields is caught immediately. test("source declares mode as an optional field inside a kilocode_change block", () => { const src = fs.readFileSync(SOURCE, "utf-8") - expect(src).toMatch(/kilocode_change start[^\n]*hint to UI clients/) + expect(src).toMatch(/kilocode_change start[^\n]*localization and mode selection hints/) expect(src).toMatch(/mode:\s*Schema\.optional\(Schema\.String\)/) expect(src).toMatch(/kilocode_change end/) }) test("source declares labelKey and descriptionKey inside a kilocode_change block", () => { const src = fs.readFileSync(SOURCE, "utf-8") - expect(src).toMatch(/kilocode_change start[^\n]*i18n keys/) + expect(src).toMatch(/kilocode_change start[^\n]*localization and mode selection hints/) expect(src).toMatch(/labelKey:\s*Schema\.optional\(Schema\.String\)/) expect(src).toMatch(/descriptionKey:\s*Schema\.optional\(Schema\.String\)/) }) diff --git a/packages/opencode/test/kilocode/read-directory.test.ts b/packages/opencode/test/kilocode/read-directory.test.ts index 765b617f2ea..eb6dc85529a 100644 --- a/packages/opencode/test/kilocode/read-directory.test.ts +++ b/packages/opencode/test/kilocode/read-directory.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { symlink } from "fs/promises" @@ -29,12 +30,12 @@ const expandCtx = { ...baseCtx, extra: { includeDirectoryFiles: true } } const it = testEffect( Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Instruction.defaultLayer, - LSP.defaultLayer, - Truncate.defaultLayer, + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(Instruction.node), + AppNodeBuilder.build(LSP.node), + AppNodeBuilder.build(Truncate.node), testInstanceStoreLayer, ), ) diff --git a/packages/opencode/test/kilocode/read-docx.test.ts b/packages/opencode/test/kilocode/read-docx.test.ts index 55f31b16a6f..1c4801d195b 100644 --- a/packages/opencode/test/kilocode/read-docx.test.ts +++ b/packages/opencode/test/kilocode/read-docx.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Layer } from "effect" import path from "path" @@ -29,12 +30,12 @@ const expanded: Tool.Context = { ...ctx, extra: { includeDirectoryFiles: true } const it = testEffect( Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Instruction.defaultLayer, - LSP.defaultLayer, - Truncate.defaultLayer, + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(Instruction.node), + AppNodeBuilder.build(LSP.node), + AppNodeBuilder.build(Truncate.node), testInstanceStoreLayer, ), ) diff --git a/packages/opencode/test/kilocode/read-notebook.test.ts b/packages/opencode/test/kilocode/read-notebook.test.ts index 7f0fbf08622..32b7ad973d5 100644 --- a/packages/opencode/test/kilocode/read-notebook.test.ts +++ b/packages/opencode/test/kilocode/read-notebook.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import path from "path" @@ -26,12 +27,12 @@ const ctx = { const it = testEffect( Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Instruction.defaultLayer, - LSP.defaultLayer, - Truncate.defaultLayer, + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(Instruction.node), + AppNodeBuilder.build(LSP.node), + AppNodeBuilder.build(Truncate.node), testInstanceStoreLayer, ), ) diff --git a/packages/opencode/test/kilocode/read-xlsx.test.ts b/packages/opencode/test/kilocode/read-xlsx.test.ts index 2e7c7c905fd..d0fdc64a2f9 100644 --- a/packages/opencode/test/kilocode/read-xlsx.test.ts +++ b/packages/opencode/test/kilocode/read-xlsx.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Cause, Effect, Exit, Layer } from "effect" import { describe, expect } from "bun:test" import { truncate } from "fs/promises" @@ -29,12 +30,12 @@ const ctx = { const it = testEffect( Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Instruction.defaultLayer, - LSP.defaultLayer, - Truncate.defaultLayer, + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(Instruction.node), + AppNodeBuilder.build(LSP.node), + AppNodeBuilder.build(Truncate.node), testInstanceStoreLayer, ), ) diff --git a/packages/opencode/test/kilocode/recall-search.test.ts b/packages/opencode/test/kilocode/recall-search.test.ts index 00469ef32a8..2145df633f5 100644 --- a/packages/opencode/test/kilocode/recall-search.test.ts +++ b/packages/opencode/test/kilocode/recall-search.test.ts @@ -1,5 +1,7 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { RecallSearch } from "../../src/kilocode/session/recall-search" import { Instance } from "../../src/kilocode/instance" import { Session } from "../../src/session/session" @@ -15,7 +17,7 @@ import { testEffect } from "../lib/effect" type Stored = T extends unknown ? Omit : never -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, Database.node]))) const add = Effect.fn("RecallSearchTest.add")(function* ( sessionID: SessionID, diff --git a/packages/opencode/test/kilocode/reference.test.ts b/packages/opencode/test/kilocode/reference.test.ts index efddb1ef380..1a14cafcce8 100644 --- a/packages/opencode/test/kilocode/reference.test.ts +++ b/packages/opencode/test/kilocode/reference.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" import path from "path" import { Cause, Effect, Exit, Layer } from "effect" @@ -6,7 +7,7 @@ import * as Reference from "../../src/kilocode/reference" import { Reference as CoreReference } from "@opencode-ai/core/reference" import { EventV2 } from "@opencode-ai/core/event" import { Global } from "@opencode-ai/core/global" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Config } from "../../src/config/config" @@ -40,11 +41,10 @@ describe("configured references", () => { publish: (definition, data) => Effect.succeed({ id: EventV2.ID.make("evt_reference_sync"), type: definition.type, data }), }) - const layer = CoreReference.layer.pipe( - Layer.provide(cache), - Layer.provide(events), - Layer.provide(Global.defaultLayer), - ) + const layer = AppNodeBuilder.build(CoreReference.node, [ + [RepositoryCache.node, cache], + [EventV2.node, events], + ]) const result = await Effect.runPromise( Effect.gen(function* () { @@ -85,11 +85,10 @@ describe("configured references", () => { return Effect.succeed({ id: EventV2.ID.make(`evt_${updates.length}`), type: definition.type, data }) }, }) - const layer = CoreReference.layer.pipe( - Layer.provide(cache), - Layer.provide(events), - Layer.provide(Global.defaultLayer), - ) + const layer = AppNodeBuilder.build(CoreReference.node, [ + [RepositoryCache.node, cache], + [EventV2.node, events], + ]) const input = { references: { docs: { path: "./docs", description: "Internal documentation", hidden: true } }, directory: "/workspace/src", @@ -106,6 +105,31 @@ describe("configured references", () => { expect(updates).toEqual(["reference.updated"]) }) + test("sync replaces stale effective references", async () => { + const cache = Layer.mock(RepositoryCache.Service, { + ensure: () => Effect.die("unexpected Git materialization"), + }) + const events = Layer.mock(EventV2.Service)({ + publish: (definition, data) => + Effect.succeed({ id: EventV2.ID.make("evt_reference_replace"), type: definition.type, data }), + }) + const layer = AppNodeBuilder.build(CoreReference.node, [ + [RepositoryCache.node, cache], + [EventV2.node, events], + ]) + + const result = await Effect.runPromise( + Effect.gen(function* () { + yield* Reference.sync({ references: { stale: "./stale" }, directory: "/workspace", worktree: "/workspace" }) + yield* Reference.sync({ references: { current: "./current" }, directory: "/workspace", worktree: "/workspace" }) + return yield* (yield* CoreReference.Service).list() + }).pipe(Effect.provide(layer), Effect.scoped), + ) + + expect(result.map((item) => item.name)).toEqual(["current"]) + expect(result[0]?.path).toBe(AbsolutePath.make(path.resolve("/workspace", "current"))) + }) + test("initializes effective references before exposing location services", async () => { await using tmp = await tmpdir({ config: { @@ -116,11 +140,14 @@ describe("configured references", () => { }, }, }) - const layer = locations.pipe(Layer.provide(Config.defaultLayer), Layer.provide(testInstanceStoreLayer)) + const layer = locations.pipe( + Layer.provide(AppNodeBuilder.build(Config.node)), + Layer.provide(testInstanceStoreLayer), + ) const result = await Effect.runPromise( Effect.gen(function* () { - const map = yield* LocationServiceMap + const map = yield* LocationServiceMap.Service return yield* CoreReference.Service.use((reference) => reference.list()).pipe( Effect.provide(map.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))), ) diff --git a/packages/opencode/test/kilocode/release-publish-scope.test.ts b/packages/opencode/test/kilocode/release-publish-scope.test.ts new file mode 100644 index 00000000000..054d8b222f3 --- /dev/null +++ b/packages/opencode/test/kilocode/release-publish-scope.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import path from "path" + +test("Kilo releases do not publish upstream-owned packages", async () => { + const root = path.join(import.meta.dir, "../../../..") + const src = await Bun.file(path.join(root, "script", "publish.ts")).text() + + expect(src).not.toContain("packages/ui/script/publish.ts") + expect(src).toContain("packages/opencode/script/publish.ts") + expect(src).toContain("packages/sdk/js/script/publish.ts") + expect(src).toContain("packages/plugin/script/publish.ts") +}) diff --git a/packages/opencode/test/kilocode/review-command.test.ts b/packages/opencode/test/kilocode/review-command.test.ts index 442f52c73d7..ec000bdc8c0 100644 --- a/packages/opencode/test/kilocode/review-command.test.ts +++ b/packages/opencode/test/kilocode/review-command.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer } from "effect" @@ -6,7 +7,7 @@ import { legacyReviewMessage, parseReviewCommand, reviewCommand } from "../../sr import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Command.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Command.node), AppNodeBuilder.build(CrossSpawnSpawner.node))) function expectReviewFixContract(text: string) { expect(text).toContain("During the initial review phase") diff --git a/packages/opencode/test/kilocode/sandbox/config-network.test.ts b/packages/opencode/test/kilocode/sandbox/config-network.test.ts index 4adcb4a1813..b702722f60c 100644 --- a/packages/opencode/test/kilocode/sandbox/config-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/config-network.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Cause, Effect, Exit, Layer } from "effect" import { expect, test } from "bun:test" import { HttpClient } from "effect/unstable/http" @@ -27,7 +28,7 @@ const ctx = { function layer(restrict?: boolean, allowedHosts: string[] = []) { return Layer.mergeAll( ToolNetwork.httpLayer, - Database.defaultLayer, + AppNodeBuilder.build(Database.node), TestConfig.layer({ get: () => Effect.succeed({ diff --git a/packages/opencode/test/kilocode/sandbox/http-tools.test.ts b/packages/opencode/test/kilocode/sandbox/http-tools.test.ts index a60f8f17a51..04f5449298a 100644 --- a/packages/opencode/test/kilocode/sandbox/http-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/http-tools.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Layer } from "effect" import { HttpClient } from "effect/unstable/http" @@ -11,7 +12,7 @@ import { Truncate } from "@/tool/truncate" import { WebFetchTool } from "@/tool/webfetch" import { testEffect } from "../../lib/effect" -const layer = Layer.mergeAll(ToolNetwork.httpLayer, Truncate.defaultLayer, Agent.defaultLayer) +const layer = Layer.mergeAll(ToolNetwork.httpLayer, AppNodeBuilder.build(Truncate.node), AppNodeBuilder.build(Agent.node)) const it = testEffect(layer) const ctx = { diff --git a/packages/opencode/test/kilocode/sandbox/macos-confinement.test.ts b/packages/opencode/test/kilocode/sandbox/macos-confinement.test.ts index 3fa7c673932..b4f60f5aad8 100644 --- a/packages/opencode/test/kilocode/sandbox/macos-confinement.test.ts +++ b/packages/opencode/test/kilocode/sandbox/macos-confinement.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect } from "bun:test" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -44,16 +45,16 @@ const runner: MutationRunner = (profile, request) => }) const it = testEffect( Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - AppProcess.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Instruction.defaultLayer, - LSP.defaultLayer, + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(AppProcess.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(Instruction.node), + AppNodeBuilder.build(LSP.node), Bus.layer, - Format.defaultLayer, - Truncate.defaultLayer, - EventV2Bridge.defaultLayer, + AppNodeBuilder.build(Format.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(EventV2Bridge.node), ), ) diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index b2fa9a670e2..543705a6be5 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { existsSync } from "node:fs" import fs from "node:fs/promises" import path from "node:path" @@ -111,6 +112,7 @@ const plugin = Layer.mock(Plugin.Service)({ }) const mcp = Layer.mock(MCP.Service)({ tools: () => Effect.succeed({}), + clients: () => Effect.succeed({}), // kilocode_change - upstream's MCP resource tools probe the clients }) const lsp = Layer.mock(LSP.Service)({ touchFile: () => Effect.void, @@ -134,10 +136,10 @@ const base = Layer.mergeAll( format, truncate, Bus.layer, - EventV2Bridge.defaultLayer, - Database.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(EventV2Bridge.node), + AppNodeBuilder.build(Database.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), RuntimeFlags.layer(), ) const registry = Layer.effect( diff --git a/packages/opencode/test/kilocode/sandbox/session.test.ts b/packages/opencode/test/kilocode/sandbox/session.test.ts index bc7d5d961ea..713bb19d6c4 100644 --- a/packages/opencode/test/kilocode/sandbox/session.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import fs from "node:fs/promises" import path from "node:path" import { $ } from "bun" @@ -6,6 +7,7 @@ import { Deferred, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Database } from "@opencode-ai/core/database/database" import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" import { BackgroundJob } from "@/background/job" import { Bus } from "@/bus" import { Config } from "@/config/config" @@ -30,22 +32,22 @@ const it = testEffect( Layer.mergeAll( Session.layer.pipe( Layer.provide(Bus.layer), - Layer.provide(Storage.defaultLayer), + Layer.provide(AppNodeBuilder.build(Storage.node)), Layer.provide(SyncEvent.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(SessionV2.defaultLayer), + Layer.provide(AppNodeBuilder.build(BackgroundJob.node)), + Layer.provide(AppNodeBuilder.build(Database.node)), + Layer.provide(AppNodeBuilder.build(EventV2Bridge.node)), + Layer.provide(AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])), // kilocode_change ), - BackgroundJob.defaultLayer, + AppNodeBuilder.build(BackgroundJob.node), Bus.layer, - Config.defaultLayer, - Database.defaultLayer, - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(Config.node), + AppNodeBuilder.build(Database.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), testInstanceStoreLayer, - Notebook.defaultLayer, - SessionStatus.defaultLayer, + AppNodeBuilder.build(Notebook.node), + AppNodeBuilder.build(SessionStatus.node), ), ) @@ -137,7 +139,9 @@ describe("sandbox session cleanup", () => { if (!status.available) return const token = SandboxInheritance.issue({ sessionID: source.id, directory: dir, count: 1 }) - const child = yield* provideInstance(worktree)(sessions.create({ title: "sandbox-child", sandboxInheritanceToken: token })) + const child = yield* provideInstance(worktree)( + sessions.create({ title: "sandbox-child", sandboxInheritanceToken: token }), + ) expect((yield* provideInstance(worktree)(SandboxPolicy.status(child.id))).enabled).toBe(true) yield* provideInstance(dir)(SandboxPolicy.toggle(source.id)) diff --git a/packages/opencode/test/kilocode/sandbox/shell-network.test.ts b/packages/opencode/test/kilocode/sandbox/shell-network.test.ts index 57ac76272e8..d099ce1587d 100644 --- a/packages/opencode/test/kilocode/sandbox/shell-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/shell-network.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { Config } from "@/config/config" @@ -17,16 +18,16 @@ import { TestConfig } from "../../fixture/config" import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture" const base = Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - RuntimeFlags.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Plugin.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(RuntimeFlags.node), testInstanceStoreLayer, - Database.defaultLayer, + AppNodeBuilder.build(Database.node), ) -const layer = Layer.mergeAll(base, Config.defaultLayer) +const layer = Layer.mergeAll(base, AppNodeBuilder.build(Config.node)) function configured(restrict: boolean) { return Layer.mergeAll( @@ -164,7 +165,7 @@ describe("model shell network integration", () => { expect(denied.accepted()).toBe(0) }) - await Effect.runPromise(Effect.scoped(effect.pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)))) + await Effect.runPromise(Effect.scoped(effect.pipe(Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))))) }, ) }) diff --git a/packages/opencode/test/kilocode/sandbox/state.test.ts b/packages/opencode/test/kilocode/sandbox/state.test.ts index 5ea2fb56934..eb6ed1cac68 100644 --- a/packages/opencode/test/kilocode/sandbox/state.test.ts +++ b/packages/opencode/test/kilocode/sandbox/state.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -18,7 +19,12 @@ import { TestInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" const it = testEffect( - Layer.mergeAll(Bus.layer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Database.defaultLayer), + Layer.mergeAll( + Bus.layer, + AppNodeBuilder.build(Config.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(Database.node), + ), ) const linux = process.platform === "linux" ? test : test.skip const posix = process.platform === "win32" ? test.skip : test @@ -36,6 +42,7 @@ test("restores the session snapshot after a backend restart", async () => { 'import { Effect, Layer } from "effect"', 'import { Config } from "@/config/config"', 'import { Database } from "@opencode-ai/core/database/database"', + 'import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"', 'import { InstanceRef } from "@/effect/instance-ref"', 'import * as SandboxPolicy from "@/kilocode/sandbox/policy"', 'import { SandboxStore } from "@/kilocode/sandbox/store"', @@ -44,7 +51,7 @@ test("restores the session snapshot after a backend restart", async () => { 'const context = { directory, worktree: directory, project: { id: "sandbox-restart", worktree: directory, vcs: "git", time: { created: 0, updated: 0 }, sandboxes: [] } }', "const cfg = JSON.parse(process.env.TEST_CONFIG)", 'const id = SessionID.make("ses_sandbox_restart")', - "const status = await SandboxPolicy.status(id).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed(cfg) })), Effect.provide(Database.defaultLayer), Effect.provideService(InstanceRef, context), Effect.runPromise)", + "const status = await SandboxPolicy.status(id).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed(cfg) })), Effect.provide(AppNodeBuilder.build(Database.node)), Effect.provideService(InstanceRef, context), Effect.runPromise)", "const state = await SandboxStore.read(directory, id)", "console.log(JSON.stringify({ status, state }))", ].join("\n") @@ -142,12 +149,13 @@ linux("reports configured network namespace availability", async () => { 'import { Effect, Layer } from "effect"', 'import { Config } from "@/config/config"', 'import { Database } from "@opencode-ai/core/database/database"', + 'import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"', 'import { InstanceRef } from "@/effect/instance-ref"', 'import * as SandboxPolicy from "@/kilocode/sandbox/policy"', 'import { SessionID } from "@/session/schema"', "const directory = process.cwd()", 'const context = { directory, worktree: directory, project: { id: "sandbox-status", worktree: directory, vcs: "git", time: { created: 0, updated: 0 }, sandboxes: [] } }', - "const status = (restrict) => SandboxPolicy.status(SessionID.make(`ses_sandbox_status_${restrict}`)).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed({ sandbox: { enabled: true, network: restrict ? 'deny' : 'allow' } }) })), Effect.provide(Database.defaultLayer), Effect.provideService(InstanceRef, context), Effect.runPromise)", + "const status = (restrict) => SandboxPolicy.status(SessionID.make(`ses_sandbox_status_${restrict}`)).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed({ sandbox: { enabled: true, network: restrict ? 'deny' : 'allow' } }) })), Effect.provide(AppNodeBuilder.build(Database.node)), Effect.provideService(InstanceRef, context), Effect.runPromise)", "const deny = await status(true)", "const allow = await status(false)", 'if (deny.available || deny.enabled || !deny.reason?.includes("Linux network sandbox")) process.exit(2)', diff --git a/packages/opencode/test/kilocode/semantic-search.test.ts b/packages/opencode/test/kilocode/semantic-search.test.ts index 2b6f6efc795..5d54531d390 100644 --- a/packages/opencode/test/kilocode/semantic-search.test.ts +++ b/packages/opencode/test/kilocode/semantic-search.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test, spyOn } from "bun:test" import path from "path" import { Effect, Layer, ManagedRuntime } from "effect" @@ -11,7 +12,7 @@ import { SessionID, MessageID } from "../../src/session/schema" import { Tool } from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" -const rt = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) +const rt = ManagedRuntime.make(Layer.mergeAll(AppNodeBuilder.build(Truncate.node), AppNodeBuilder.build(Agent.node))) async function initTool() { return rt.runPromise( diff --git a/packages/opencode/test/kilocode/server/cloud-session-import.test.ts b/packages/opencode/test/kilocode/server/cloud-session-import.test.ts index 85de22ee6c5..8ee2eab3c5e 100644 --- a/packages/opencode/test/kilocode/server/cloud-session-import.test.ts +++ b/packages/opencode/test/kilocode/server/cloud-session-import.test.ts @@ -106,9 +106,19 @@ function data(diff = false) { async function request(directory: string, body: unknown) { const auth = process.env.KILO_AUTH_CONTENT + const fetcher = globalThis.fetch const token = `test-${crypto.randomUUID()}` bodies.set(token, body) process.env.KILO_AUTH_CONTENT = JSON.stringify({ kilo: { type: "api", key: token } }) + globalThis.fetch = Object.assign( + (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { + const req = new Request(input, init) + const url = new URL(req.url) + if (url.pathname !== "/api/session/ses_cloud/export") return fetcher(req) + return fetcher(new Request(`${ingest.url.origin}${url.pathname}${url.search}`, req)) + }, + { preconnect: fetcher.preconnect }, + ) try { return await HttpApiApp.webHandler().handler( new Request(`http://localhost${KiloGatewayPaths.cloudSessionImport}`, { @@ -120,6 +130,7 @@ async function request(directory: string, body: unknown) { ) } finally { bodies.delete(token) + globalThis.fetch = fetcher if (auth === undefined) delete process.env.KILO_AUTH_CONTENT else process.env.KILO_AUTH_CONTENT = auth } diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 85bbd555cc7..56866d2e473 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -559,14 +559,8 @@ export const kiloScenarios: Scenario[] = [ check(body === true, "skill removal should return true") const location = path.join(directory(ctx), ".kilo/skill/httpapi-remove/SKILL.md") const sentinel = path.join(directory(ctx), ".kilo/skill/httpapi-remove/KEEP.txt") - check( - !(yield* Effect.promise(() => Bun.file(location).exists())), - "removed skill should not remain on disk", - ) - check( - yield* Effect.promise(() => Bun.file(sentinel).exists()), - "skill removal should preserve sibling files", - ) + check(!(yield* Effect.promise(() => Bun.file(location).exists())), "removed skill should not remain on disk") + check(yield* Effect.promise(() => Bun.file(sentinel).exists()), "skill removal should preserve sibling files") }), ), http.protected diff --git a/packages/opencode/test/kilocode/server/httpapi-global-sse.test.ts b/packages/opencode/test/kilocode/server/httpapi-global-sse.test.ts index 103ac6624af..be43c0e435a 100644 --- a/packages/opencode/test/kilocode/server/httpapi-global-sse.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-global-sse.test.ts @@ -36,7 +36,7 @@ const apiLayer = HttpRouter.serve( upgrade: () => Effect.void, }), ), - Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })), + Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })), // Raw HttpApi routes expose an opaque handler context at the web boundary. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion Layer.provide(Layer.succeedContext(Context.empty() as Context.Context)), diff --git a/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts b/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts index 115d4415589..60d9f5eff6d 100644 --- a/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts +++ b/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { NodeHttpServer } from "@effect/platform-node" import { Database } from "@opencode-ai/core/database/database" import { describe, expect } from "bun:test" @@ -55,9 +56,9 @@ const layer = HttpRouter.serve( store, cache, session, - EventV2Bridge.defaultLayer, + AppNodeBuilder.build(EventV2Bridge.node), ]), - Layer.provide(Database.defaultLayer), + Layer.provide(AppNodeBuilder.build(Database.node)), Layer.provide(storage), ), { disableListenLog: true, disableLogger: true }, diff --git a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts index bd9a8f31c7e..a9ee6d5b480 100644 --- a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts +++ b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts @@ -1,31 +1,29 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Effect, Exit, Fiber } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../../src/bus" import * as Config from "../../../src/config/config" import { AllowEverythingPermission } from "../../../src/kilocode/permission/allow-everything" import { Permission } from "../../../src/permission" -import { EventV2Bridge } from "../../../src/event-v2-bridge" import { PermissionV1 } from "@opencode-ai/core/v1/permission" -import { Database } from "@opencode-ai/core/database/database" import { provideTestInstance } from "../../fixture/fixture" import { Server } from "../../../src/server/server" import { Session } from "../../../src/session/session" import { provideTmpdirInstance, tmpdir } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" -const bus = Bus.layer -const env = Layer.mergeAll( - Permission.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - ), - Config.defaultLayer, - Session.defaultLayer, - bus, - CrossSpawnSpawner.defaultLayer, +const env = LayerNode.compile( + LayerNode.group([ + Permission.node, + Config.node, + Session.node, + SessionProjector.node, + Bus.node, + CrossSpawnSpawner.node, + ]), ) const it = testEffect(env) const original = { diff --git a/packages/opencode/test/kilocode/session-compaction-cap.test.ts b/packages/opencode/test/kilocode/session-compaction-cap.test.ts index 73fb4ad350e..e8fa8f40143 100644 --- a/packages/opencode/test/kilocode/session-compaction-cap.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-cap.test.ts @@ -3,16 +3,15 @@ // overflows the model context, and that the exhausted turn surfaces as an // error (rather than silently completing). -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" import { Deferred, Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" import { Database } from "@opencode-ai/core/database/database" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "../../src/background/job" import { Bus } from "../../src/bus" import { Command } from "../../src/command" -import { Auth } from "../../src/auth" import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -34,7 +33,6 @@ import { Provider as ProviderSvc } from "../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Question } from "../../src/question" -import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { Session } from "../../src/session/session" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" @@ -51,8 +49,6 @@ import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" -import { Storage } from "../../src/storage/storage" -import { SyncEvent } from "../../src/sync" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import * as Log from "@opencode-ai/core/util/log" @@ -95,6 +91,8 @@ const mcp = Layer.succeed( tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + instructions: () => Effect.succeed([]), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, @@ -130,87 +128,59 @@ const lsp = Layer.succeed( }), ) -const status = Layer.mergeAll(SessionStatus.defaultLayer, Bus.layer) -const runState = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const serverNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) +const root = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memoryNode, + serverNode, +]) function makeHttp() { - const deps = Layer.mergeAll( - Session.defaultLayer, - BackgroundJob.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.defaultLayer, - plugin, - Config.defaultLayer, - RuntimeFlags.layer(), - ProviderSvc.defaultLayer, - lsp, - mcp, - FSUtil.defaultLayer, - SyncEvent.defaultLayer, - EventV2Bridge.defaultLayer, - Database.defaultLayer, - status, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(KiloSessions.testLayer), - Layer.provide(Auth.defaultLayer), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provideMerge(deps), - ) - const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(runState), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provideMerge(deps), - ), - ).pipe( - Layer.provide( - Layer.mergeAll( - summary, - deps, - Config.defaultLayer, - RuntimeFlags.layer(), - BackgroundJob.defaultLayer, - Bus.layer, - infra, - Storage.defaultLayer, - ), - ), - ) + return LayerNode.compile(root, [ + [SessionSummary.node, summary], + [Plugin.node, plugin], + [LSP.node, lsp], + [MCP.node, mcp], + [RuntimeFlags.node, RuntimeFlags.layer()], + [KiloSessions.node, KiloSessions.testLayer], + ]) } const it = testEffect(makeHttp()) diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index 944b1707ae3..ecc1aa58554 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -1,3 +1,4 @@ +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterAll, afterEach, beforeAll, describe, expect, mock, test } from "bun:test" import { Effect, Layer, ManagedRuntime } from "effect" import fs from "fs/promises" @@ -34,7 +35,10 @@ import { ProviderTest } from "../fake/provider" import { tmpdir } from "../fixture/fixture" import { Flag } from "@opencode-ai/core/flag/flag" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" import { remove as cleanup } from "./cleanup" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Provider } from "../../src/provider/provider" const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("test-model") @@ -44,6 +48,8 @@ const agents = Layer.mock(Agent.Service)({ }) const previous = Flag.KILO_DB const dbfile = path.join(os.tmpdir(), `kilo-compaction-chunks-${process.pid}-${crypto.randomUUID()}.db`) +const layer = LayerNode.compile(LayerNode.group([SessionNs.node, SessionProjector.node])) +const runtime = makeRuntime(SessionNs.Service, layer) beforeAll(async () => { await fs.rm(dbfile, { force: true }) @@ -51,16 +57,13 @@ beforeAll(async () => { }) afterAll(async () => { + await runtime.dispose() await AppRuntime.dispose() await disposeTestRuntime() Flag.KILO_DB = previous await Promise.all([dbfile, `${dbfile}-wal`, `${dbfile}-shm`].map(cleanup)) }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} - const store = { updateMessage: (msg: T) => Effect.promise(() => svc.updateMessage(msg)), updatePart: (part: T) => Effect.promise(() => svc.updatePart(part)), @@ -68,16 +71,16 @@ const store = { const svc = { create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) + return runtime.runPromise((svc) => svc.create(input)) }, messages(input: Parameters[0]) { - return run(SessionNs.Service.use((svc) => svc.messages(input))) + return runtime.runPromise((svc) => svc.messages(input)) }, updateMessage(msg: T) { - return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) + return runtime.runPromise((svc) => svc.updateMessage(msg)) }, updatePart(part: T) { - return run(SessionNs.Service.use((svc) => svc.updatePart(part))) + return runtime.runPromise((svc) => svc.updatePart(part)) }, } @@ -221,27 +224,29 @@ function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error }) }), ) + const processorNode = LayerNode.make({ + service: SessionProcessorModule.SessionProcessor.Service, + layer: processor, + deps: [SessionNs.node], + }) const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 10_000, output: 1_000 } }) return { calls, outputs, rt: ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus).pipe( - Layer.provide(ProviderTest.fake({ model }).layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(agents), - Layer.provide(Plugin.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.layer({ outputTokenMax })), - Layer.provide(bus), - Layer.provide( + LayerNode.compile(LayerNode.group([SessionCompaction.node, SessionNs.node, SessionProjector.node, Bus.node]), [ + [SessionProcessorModule.SessionProcessor.node, processorNode], + [Provider.node, ProviderTest.fake({ model }).layer], + [Agent.node, agents], + [RuntimeFlags.node, RuntimeFlags.layer({ outputTokenMax })], + [ + Config.node, Layer.mock(Config.Service)({ get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), + directories: () => Effect.succeed([]), }), - ), - ), + ], + ]), ), } } @@ -289,34 +294,30 @@ async function failure(error?: MessageV2.Assistant["error"], empty = false) { } function liveRuntime(layer: Layer.Layer, context = 10_000) { - const bus = Bus.layer - const status = SessionStatus.layer.pipe(Layer.provide(bus), Layer.provide(EventV2Bridge.defaultLayer)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), - ) const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } }) return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( - Layer.provide(ProviderTest.fake({ model }).layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(layer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.layer()), - Layer.provide(status), - Layer.provide(bus), - Layer.provide( - Layer.mock(Config.Service)({ - get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), - }), - ), + LayerNode.compile( + LayerNode.group([ + SessionCompaction.node, + SessionProcessorModule.SessionProcessor.node, + SessionNs.node, + SessionProjector.node, + Bus.node, + SessionStatus.node, + ]), + [ + [SessionSummary.node, summary], + [Provider.node, ProviderTest.fake({ model }).layer], + [LLM.node, layer], + [RuntimeFlags.node, RuntimeFlags.layer()], + [ + Config.node, + Layer.mock(Config.Service)({ + get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), + directories: () => Effect.succeed([]), + }), + ], + ], ), ) } @@ -711,136 +712,135 @@ describe("KiloCompactionChunks", () => { }) }) - test( - "compaction must not leak maxOutputTokens into agent options", - async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const first = await user(session.id, "first " + "a".repeat(20_000)) - await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000)) - const second = await user(session.id, "second " + "c".repeat(20_000)) - await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(20_000)) - await Effect.runPromise( - KiloSessionCompaction.create({ - session: store, - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }), - ) + test("compaction must not leak maxOutputTokens into agent options", async () => { + await using tmp = await tmpdir() + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const first = await user(session.id, "first " + "a".repeat(20_000)) + await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000)) + const second = await user(session.id, "second " + "c".repeat(20_000)) + await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(20_000)) + await Effect.runPromise( + KiloSessionCompaction.create({ + session: store, + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }), + ) - const captured: Array<{ opts: Record; modelLimitOutput: number }> = [] - const bus = Bus.layer - const processor = Layer.effect( - SessionProcessorModule.SessionProcessor.Service, - Effect.gen(function* () { - const sessions = yield* SessionNs.Service - return SessionProcessorModule.SessionProcessor.Service.of({ - create: Effect.fn("TestSessionProcessorLeak.create")((input) => - Effect.succeed({ - get message() { - return input.assistantMessage - }, - updateToolCall: Effect.fn("TestSessionProcessorLeak.updateToolCall")(() => - Effect.succeed(undefined), - ), - metadata: Effect.fn("TestSessionProcessorLeak.metadata")(() => Effect.void), - completeToolCall: Effect.fn("TestSessionProcessorLeak.completeToolCall")(() => Effect.void), - process: Effect.fn("TestSessionProcessorLeak.process")((stream: LLM.StreamInput) => - Effect.gen(function* () { - captured.push({ - opts: stream.agent.options as Record, - modelLimitOutput: stream.model.limit.output, - }) - const text = stream.messages.some((msg) => - JSON.stringify(msg).includes("Create a new anchored summary"), - ) - ? "final summary" - : "chunk summary" - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - type: "text", - text, - }) - input.assistantMessage.finish = "stop" - return "continue" as const - }), - ), - } satisfies SessionProcessor.Handle), - ), - }) - }), - ) + const captured: Array<{ opts: Record; modelLimitOutput: number }> = [] + const bus = Bus.layer + const processor = Layer.effect( + SessionProcessorModule.SessionProcessor.Service, + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + return SessionProcessorModule.SessionProcessor.Service.of({ + create: Effect.fn("TestSessionProcessorLeak.create")((input) => + Effect.succeed({ + get message() { + return input.assistantMessage + }, + updateToolCall: Effect.fn("TestSessionProcessorLeak.updateToolCall")(() => Effect.succeed(undefined)), + metadata: Effect.fn("TestSessionProcessorLeak.metadata")(() => Effect.void), + completeToolCall: Effect.fn("TestSessionProcessorLeak.completeToolCall")(() => Effect.void), + process: Effect.fn("TestSessionProcessorLeak.process")((stream: LLM.StreamInput) => + Effect.gen(function* () { + captured.push({ + opts: stream.agent.options as Record, + modelLimitOutput: stream.model.limit.output, + }) + const text = stream.messages.some((msg) => + JSON.stringify(msg).includes("Create a new anchored summary"), + ) + ? "final summary" + : "chunk summary" + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "text", + text, + }) + input.assistantMessage.finish = "stop" + return "continue" as const + }), + ), + } satisfies SessionProcessor.Handle), + ), + }) + }), + ) - const model = ProviderTest.model({ - providerID, - id: modelID, - limit: { context: 10_000, output: 1_000 }, - }) - const outputTokenMax = 512 - const rt = ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus).pipe( - Layer.provide(ProviderTest.fake({ model }).layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(agents), - Layer.provide(Plugin.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.layer({ outputTokenMax })), - Layer.provide(bus), - Layer.provide( + const model = ProviderTest.model({ + providerID, + id: modelID, + limit: { context: 10_000, output: 1_000 }, + }) + const processorNode = LayerNode.make({ + service: SessionProcessorModule.SessionProcessor.Service, + layer: processor, + deps: [SessionNs.node], + }) + const outputTokenMax = 512 + const rt = ManagedRuntime.make( + LayerNode.compile( + LayerNode.group([SessionCompaction.node, SessionNs.node, SessionProjector.node, Bus.node]), + [ + [SessionProcessorModule.SessionProcessor.node, processorNode], + [Provider.node, ProviderTest.fake({ model }).layer], + [Agent.node, agents], + [RuntimeFlags.node, RuntimeFlags.layer({ outputTokenMax })], + [ + Config.node, Layer.mock(Config.Service)({ get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), + directories: () => Effect.succeed([]), }), - ), + ], + ], + ), + ) + + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), ), ) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - expect(result).toBe("continue") - expect(captured.length).toBeGreaterThan(0) - // Negative assertion (the bug surfacing): - // maxOutputTokens must not appear in agent.options that the - // worker hands to the LLM. Today a strict OpenAI-compatible - // upstream rejects that field with - // Unsupported parameter(s): maxOutputTokens`. - for (const c of captured) { - expect(c.opts.maxOutputTokens).toBeUndefined() - } - // Positive assertion (budget preserved through an independent path): - // the constrained model still threads a tightened output limit - // through to every worker. If a future "fix" accidentally severs - // the only budget source along with the leak, this fails. - for (const c of captured) { - expect(c.modelLimitOutput).toBeLessThanOrEqual(outputTokenMax) - } - } finally { - await rt.dispose() + expect(result).toBe("continue") + expect(captured.length).toBeGreaterThan(0) + // Negative assertion (the bug surfacing): + // maxOutputTokens must not appear in agent.options that the + // worker hands to the LLM. Today a strict OpenAI-compatible + // upstream rejects that field with + // Unsupported parameter(s): maxOutputTokens`. + for (const c of captured) { + expect(c.opts.maxOutputTokens).toBeUndefined() } - }, - }) - }, - 30_000, - ) + // Positive assertion (budget preserved through an independent path): + // the constrained model still threads a tightened output limit + // through to every worker. If a future "fix" accidentally severs + // the only budget source along with the leak, this fails. + for (const c of captured) { + expect(c.modelLimitOutput).toBeLessThanOrEqual(outputTokenMax) + } + } finally { + await rt.dispose() + } + }, + }) + }, 30_000) }) diff --git a/packages/opencode/test/kilocode/session-fork-remap.test.ts b/packages/opencode/test/kilocode/session-fork-remap.test.ts index e3793ae98e5..5d0586988c6 100644 --- a/packages/opencode/test/kilocode/session-fork-remap.test.ts +++ b/packages/opencode/test/kilocode/session-fork-remap.test.ts @@ -1,3 +1,6 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" import { Effect } from "effect" import { HttpRouter } from "effect/unstable/http" @@ -22,12 +25,15 @@ import path from "path" import os from "os" import fs from "fs/promises" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" import { remove as cleanup } from "./cleanup" Log.init({ print: false }) const previous = Flag.KILO_DB const dbfile = path.join(os.tmpdir(), `kilo-fork-${process.pid}-${crypto.randomUUID()}.db`) +const layer = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])) +const runtime = makeRuntime(Session.Service, layer) beforeAll(async () => { await fs.rm(dbfile, { force: true }) @@ -35,6 +41,7 @@ beforeAll(async () => { }) afterAll(async () => { + await runtime.dispose() await AppRuntime.dispose() await disposeTestRuntime() Flag.KILO_DB = previous @@ -43,14 +50,14 @@ afterAll(async () => { const sessions = { create: (input?: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))), - list: () => Effect.runPromise(Session.Service.use((svc) => svc.list()).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.create(input)), + list: () => runtime.runPromise((svc) => svc.list()), messages: (input: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.messages(input)), updateMessage: (msg: T) => - Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.updateMessage(msg)), updatePart: (part: T) => - Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.updatePart(part)), } afterEach(async () => { @@ -74,7 +81,7 @@ async function instance(input: { directory: string; fn: () => R }) { .onConflictDoNothing() .run() .pipe(Effect.orDie) - }).pipe(Effect.provide(CoreDatabase.defaultLayer)), + }).pipe(Effect.provide(AppNodeBuilder.build(CoreDatabase.node))), }) } @@ -391,7 +398,7 @@ describe("Session.fork task detachment", () => { .get() .pipe(Effect.orDie), ]) - }).pipe(Effect.provide(CoreDatabase.defaultLayer)), + }).pipe(Effect.provide(AppNodeBuilder.build(CoreDatabase.node))), ) expect(rows).toEqual([ diff --git a/packages/opencode/test/kilocode/session-import-service.test.ts b/packages/opencode/test/kilocode/session-import-service.test.ts index fadf279cdeb..91bd463ae1e 100644 --- a/packages/opencode/test/kilocode/session-import-service.test.ts +++ b/packages/opencode/test/kilocode/session-import-service.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { Database } from "@opencode-ai/core/database/database" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -14,7 +15,7 @@ import { tmpdir } from "../fixture/fixture" const projectID = ProjectV2.ID.make("proj_test") -const runtime = makeRuntime(Database.Service, Database.defaultLayer) +const runtime = makeRuntime(Database.Service, AppNodeBuilder.build(Database.node)) const db = (effect: Effect.Effect) => runtime.runPromise(() => effect) async function prepare() { diff --git a/packages/opencode/test/kilocode/session-list.test.ts b/packages/opencode/test/kilocode/session-list.test.ts index 2ebea50b240..53ca4c75100 100644 --- a/packages/opencode/test/kilocode/session-list.test.ts +++ b/packages/opencode/test/kilocode/session-list.test.ts @@ -1,5 +1,7 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import path from "path" import { seedProject } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -14,7 +16,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" Log.init({ print: false }) -const layer = Layer.mergeAll(Session.defaultLayer, Database.defaultLayer) +const layer = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, Database.node])) const it = testEffect(layer) describe("Kilo Session.list", () => { diff --git a/packages/opencode/test/kilocode/session-model-usage.test.ts b/packages/opencode/test/kilocode/session-model-usage.test.ts index 9a95de988b6..8e606287f3c 100644 --- a/packages/opencode/test/kilocode/session-model-usage.test.ts +++ b/packages/opencode/test/kilocode/session-model-usage.test.ts @@ -1,5 +1,7 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { ModelUsage } from "@/kilocode/session/model-usage" import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectV2 } from "@opencode-ai/core/project" @@ -15,7 +17,7 @@ import { eq } from "drizzle-orm" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, Database.node]))) const ref = (providerID: string, modelID: string) => ({ providerID: ProviderV2.ID.make(providerID), diff --git a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts index 77ba608e751..471cfe776c2 100644 --- a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts +++ b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Context, Effect, Layer } from "effect" @@ -48,6 +50,8 @@ class TestLLM extends Context.Service< } >()("@test/EmptyToolCallsLLM") {} +class State extends Context.Service()("@test/EmptyToolCallsState") {} + function model(selection = ref): Provider.Model { return { id: selection.modelID, @@ -76,48 +80,58 @@ function usage() { } } -const llm = Layer.unwrap( - Effect.gen(function* () { - const queue: Script[] = [] - const push = (item: Script) => { - queue.push(item) - return Effect.void - } - const reply = (...items: Event[]) => push(Stream.make(...items)) - return Layer.mergeAll( - Layer.succeed( - LLM.Service, - LLM.Service.of({ - stream: () => { - const item = queue.shift() ?? Stream.empty - return item - }, - }), - ), - Layer.succeed(TestLLM, TestLLM.of({ reply, script: push })), - ) - }), -) - -const status = Layer.mergeAll(SessionStatus.defaultLayer, Bus.layer) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) -const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - AgentSvc.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer(), - SessionSummary.defaultLayer, - Image.defaultLayer, - SyncEvent.defaultLayer, - EventV2Bridge.defaultLayer, - Database.defaultLayer, - status, - llm, -).pipe(Layer.provideMerge(infra)) -const env = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) +const stateNode = LayerNode.make({ + service: State, + layer: Layer.sync(State, () => State.of({ queue: [] })), + deps: [], +}) +const llmNode = LayerNode.make({ + service: LLM.Service, + layer: Layer.effect( + LLM.Service, + Effect.gen(function* () { + const state = yield* State + return LLM.Service.of({ stream: () => state.queue.shift() ?? Stream.empty }) + }), + ), + deps: [stateNode], +}) +const testNode = LayerNode.make({ + service: TestLLM, + layer: Layer.effect( + TestLLM, + Effect.gen(function* () { + const state = yield* State + const push = (item: Script) => Effect.sync(() => state.queue.push(item)).pipe(Effect.asVoid) + return TestLLM.of({ reply: (...items) => push(Stream.make(...items)), script: push }) + }), + ), + deps: [stateNode], +}) +const root = LayerNode.group([ + SessionProcessor.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + AgentSvc.node, + Permission.node, + Plugin.node, + Config.node, + SessionSummary.node, + Image.node, + SessionStatus.node, + EventV2Bridge.node, + Database.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + LLM.node, + testNode, +]) +const env = LayerNode.compile(root, [ + [LLM.node, llmNode], + [RuntimeFlags.node, RuntimeFlags.layer()], +]).pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, Bus.layer, SyncEvent.defaultLayer))) const it = testEffect(env) diff --git a/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts b/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts index cfb8f3de051..e6dec024be1 100644 --- a/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts +++ b/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { describe, expect, spyOn } from "bun:test" import { APICallError } from "ai" @@ -52,6 +54,10 @@ class TestLLM extends Context.Service< } >()("@test/IncompleteResponseRetryLLM") {} +class State extends Context.Service()( + "@test/IncompleteResponseRetryState", +) {} + function model(): Provider.Model { return { id: ref.modelID, @@ -107,63 +113,72 @@ function retryable429() { }) } -const llm = Layer.unwrap( - Effect.gen(function* () { - const queue: Script[] = [] - let calls = 0 - const push = (stream: Script) => { - queue.push(stream) - return Effect.void - } - return Layer.mergeAll( - Layer.succeed( - LLM.Service, - LLM.Service.of({ - stream: () => { - calls += 1 - return queue.shift() ?? Stream.fail(new Error("unexpected extra llm call")) - }, - }), - ), - Layer.succeed( - TestLLM, - TestLLM.of({ - push, - reply: (...events) => push(Stream.make(...events)), - calls: Effect.sync(() => calls), - }), - ), - ) - }), -) - const reference = Layer.mock(Reference.Service, { list: () => Effect.succeed([]), }) -const status = Layer.mergeAll(SessionStatus.defaultLayer, Bus.layer) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +const stateNode = LayerNode.make({ + service: State, + layer: Layer.sync(State, () => State.of({ queue: [], calls: 0 })), + deps: [], +}) +const llmNode = LayerNode.make({ + service: LLM.Service, + layer: Layer.effect( + LLM.Service, + Effect.gen(function* () { + const state = yield* State + return LLM.Service.of({ + stream: () => { + state.calls += 1 + return state.queue.shift() ?? Stream.fail(new Error("unexpected extra llm call")) + }, + }) + }), + ), + deps: [stateNode], +}) +const testNode = LayerNode.make({ + service: TestLLM, + layer: Layer.effect( + TestLLM, + Effect.gen(function* () { + const state = yield* State + const push = (stream: Script) => Effect.sync(() => state.queue.push(stream)).pipe(Effect.asVoid) + return TestLLM.of({ + push, + reply: (...events) => push(Stream.make(...events)), + calls: Effect.sync(() => state.calls), + }) + }), + ), + deps: [stateNode], +}) +const root = LayerNode.group([ + SessionProcessor.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + AgentSvc.node, + Permission.node, + Plugin.node, + Config.node, + SessionSummary.node, + Image.node, + SessionStatus.node, + EventV2Bridge.node, + Database.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + LLM.node, + testNode, +]) const env = (event = false) => - SessionProcessor.layer.pipe( - Layer.provideMerge( - Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - AgentSvc.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer({ experimentalEventSystem: event }), - reference, - SessionSummary.defaultLayer, - Image.defaultLayer, - SyncEvent.defaultLayer, - EventV2Bridge.defaultLayer, - Database.defaultLayer, - status, - llm, - ).pipe(Layer.provideMerge(infra)), - ), - Layer.provide(reference), + LayerNode.compile(root, [ + [LLM.node, llmNode], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: event })], + ]).pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, Bus.layer, SyncEvent.defaultLayer, reference)), ) const it = testEffect(env()) @@ -559,16 +574,23 @@ describe("session processor incomplete response retry", () => { ), ) - eventIt.effect("does not retry when Event V2 mirroring is enabled", () => + eventIt.effect("retries an empty response the same way when the event system flag is on", () => provideTmpdirProject( (dir) => Effect.gen(function* () { const ctx = yield* setup(dir) yield* ctx.test.reply(...empty()) + yield* ctx.test.reply(...success()) + const delay = spyOn(SessionRetry, "delay").mockReturnValue(0) - expect(yield* ctx.handle.process(ctx.input)).toBe("continue") - expect(yield* ctx.test.calls).toBe(1) - expect(ctx.handle.message.finish).toBe("unknown") + try { + expect(yield* ctx.handle.process(ctx.input)).toBe("continue") + } finally { + delay.mockRestore() + } + + expect(yield* ctx.test.calls).toBe(2) + expect(ctx.handle.message.finish).toBe("stop") }), { git: true }, ), diff --git a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts index 1d04473d5cc..c80849aa7e2 100644 --- a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts +++ b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { describe, expect, spyOn } from "bun:test" import { Context, Effect, Layer } from "effect" @@ -47,6 +49,8 @@ class TestLLM extends Context.Service< } >()("@test/OfflineLLM") {} +class State extends Context.Service()("@test/OfflineLLMState") {} + function model(): Provider.Model { return { id: "test-model", @@ -75,47 +79,59 @@ function usage() { } } -const llm = Layer.unwrap( - Effect.gen(function* () { - const queue: Script[] = [] - const push = (item: Script) => { - queue.push(item) - return Effect.void - } - return Layer.mergeAll( - Layer.succeed( - LLM.Service, - LLM.Service.of({ - stream: () => { - const item = queue.shift() ?? Stream.empty - return item - }, - }), - ), - Layer.succeed(TestLLM, TestLLM.of({ push })), - ) - }), -) - -const status = Layer.mergeAll(SessionStatus.defaultLayer, Bus.layer) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) -const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - AgentSvc.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer(), - SessionSummary.defaultLayer, - Image.defaultLayer, - SyncEvent.defaultLayer, - EventV2Bridge.defaultLayer, - Database.defaultLayer, - status, - llm, -).pipe(Layer.provideMerge(infra)) -const env = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) +const stateNode = LayerNode.make({ + service: State, + layer: Layer.sync(State, () => State.of({ queue: [] })), + deps: [], +}) +const llmNode = LayerNode.make({ + service: LLM.Service, + layer: Layer.effect( + LLM.Service, + Effect.gen(function* () { + const state = yield* State + return LLM.Service.of({ stream: () => state.queue.shift() ?? Stream.empty }) + }), + ), + deps: [stateNode], +}) +const testNode = LayerNode.make({ + service: TestLLM, + layer: Layer.effect( + TestLLM, + Effect.gen(function* () { + const state = yield* State + return TestLLM.of({ + push: (item) => Effect.sync(() => state.queue.push(item)).pipe(Effect.asVoid), + }) + }), + ), + deps: [stateNode], +}) +const root = LayerNode.group([ + SessionProcessor.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + AgentSvc.node, + Permission.node, + Plugin.node, + Config.node, + SessionSummary.node, + Image.node, + SessionStatus.node, + EventV2Bridge.node, + Database.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + LLM.node, + testNode, +]) +const env = LayerNode.compile(root, [ + [LLM.node, llmNode], + [RuntimeFlags.node, RuntimeFlags.layer()], +]).pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, Bus.layer, SyncEvent.defaultLayer))) const it = testEffect(env) diff --git a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts index f7220f1084c..571633b5425 100644 --- a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts +++ b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts @@ -4,6 +4,8 @@ // transitively load flag.ts to ensure the env is captured at load time. process.env.KILO_SESSION_RETRY_LIMIT = "2" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { afterEach, describe, expect, spyOn } from "bun:test" import { APICallError } from "ai" @@ -55,6 +57,8 @@ class TestLLM extends Context.Service< } >()("@test/RetryLimitLLM") {} +class State extends Context.Service()("@test/RetryLimitLLMState") {} + function model(): Provider.Model { return { id: "test-model", @@ -86,49 +90,65 @@ function retryable429() { }) } -const llm = Layer.unwrap( - Effect.gen(function* () { - const queue: Script[] = [] - let calls = 0 - const push = (item: Script) => { - queue.push(item) - return Effect.void - } - return Layer.mergeAll( - Layer.succeed( - LLM.Service, - LLM.Service.of({ - stream: () => { - calls += 1 - const item = queue.shift() ?? Stream.fail(new Error("unexpected extra llm call")) - return item - }, - }), - ), - Layer.succeed(TestLLM, TestLLM.of({ push, calls: Effect.sync(() => calls) })), - ) - }), -) - -const status = Layer.mergeAll(SessionStatus.defaultLayer, Bus.layer) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) -const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - AgentSvc.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer(), - SessionSummary.defaultLayer, - Image.defaultLayer, - SyncEvent.defaultLayer, - EventV2Bridge.defaultLayer, - Database.defaultLayer, - status, - llm, -).pipe(Layer.provideMerge(infra)) -const env = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) +const stateNode = LayerNode.make({ + service: State, + layer: Layer.sync(State, () => State.of({ queue: [], calls: 0 })), + deps: [], +}) +const llmNode = LayerNode.make({ + service: LLM.Service, + layer: Layer.effect( + LLM.Service, + Effect.gen(function* () { + const state = yield* State + return LLM.Service.of({ + stream: () => { + state.calls += 1 + return state.queue.shift() ?? Stream.fail(new Error("unexpected extra llm call")) + }, + }) + }), + ), + deps: [stateNode], +}) +const testNode = LayerNode.make({ + service: TestLLM, + layer: Layer.effect( + TestLLM, + Effect.gen(function* () { + const state = yield* State + return TestLLM.of({ + push: (item) => Effect.sync(() => state.queue.push(item)).pipe(Effect.asVoid), + calls: Effect.sync(() => state.calls), + }) + }), + ), + deps: [stateNode], +}) +const root = LayerNode.group([ + SessionProcessor.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + AgentSvc.node, + Permission.node, + Plugin.node, + Config.node, + SessionSummary.node, + Image.node, + SessionStatus.node, + EventV2Bridge.node, + Database.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + LLM.node, + testNode, +]) +const env = LayerNode.compile(root, [ + [LLM.node, llmNode], + [RuntimeFlags.node, RuntimeFlags.layer()], +]).pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, Bus.layer, SyncEvent.defaultLayer))) const it = testEffect(env) diff --git a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts index 62fa04bee92..3ae6024893e 100644 --- a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts @@ -2,16 +2,15 @@ // Ensures Kilo's post-filterCompacted trim and post-summary media strip are // applied before messages are serialized for the provider request. -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" import { Database } from "@opencode-ai/core/database/database" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "../../src/background/job" import { Bus } from "../../src/bus" import { Command } from "../../src/command" -import { Auth } from "../../src/auth" import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -30,7 +29,6 @@ import { Provider as ProviderSvc } from "../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Question } from "../../src/question" -import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { Session } from "../../src/session/session" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" @@ -47,8 +45,6 @@ import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" -import { Storage } from "../../src/storage/storage" -import { SyncEvent } from "../../src/sync" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions" @@ -88,6 +84,8 @@ const mcp = Layer.succeed( tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + instructions: () => Effect.succeed([]), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, @@ -123,87 +121,59 @@ const lsp = Layer.succeed( }), ) -const status = Layer.mergeAll(SessionStatus.defaultLayer, Bus.layer) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const serverNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) +const root = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memoryNode, + serverNode, +]) function makeHttp() { - const deps = Layer.mergeAll( - Session.defaultLayer, - BackgroundJob.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.defaultLayer, - plugin, - Config.defaultLayer, - RuntimeFlags.layer(), - ProviderSvc.defaultLayer, - lsp, - mcp, - FSUtil.defaultLayer, - SyncEvent.defaultLayer, - EventV2Bridge.defaultLayer, - Database.defaultLayer, - status, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(KiloSessions.testLayer), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provideMerge(deps), - ) - const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provideMerge(deps), - ), - ).pipe( - Layer.provide( - Layer.mergeAll( - summary, - deps, - Config.defaultLayer, - RuntimeFlags.layer(), - BackgroundJob.defaultLayer, - Bus.layer, - infra, - Storage.defaultLayer, - ), - ), - ) + return LayerNode.compile(root, [ + [SessionSummary.node, summary], + [Plugin.node, plugin], + [LSP.node, lsp], + [MCP.node, mcp], + [RuntimeFlags.node, RuntimeFlags.layer()], + [KiloSessions.node, KiloSessions.testLayer], + ]) } const it = testEffect(makeHttp()) diff --git a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts index fe81f8880b9..ce2485be22c 100644 --- a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" @@ -33,6 +35,7 @@ import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { LLM } from "../../src/session/llm" +import { MessageV2 } from "../../src/session/message-v2" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" @@ -44,8 +47,6 @@ import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" -import { Storage } from "../../src/storage/storage" -import { SyncEvent } from "../../src/sync" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" @@ -88,6 +89,8 @@ const mcp = Layer.succeed( tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + instructions: () => Effect.succeed([]), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, @@ -123,89 +126,62 @@ const lsp = Layer.succeed( }), ) -const status = Layer.mergeAll(SessionStatus.defaultLayer, Bus.layer) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +// One compiled graph, mirroring test/session/prompt.test.ts. Effect v4 does +// not memoize nested layers, so per-service AppNodeBuilder.build calls each stood up their own +// Database and sessions written by the test were invisible to the prompt loop. +const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const testLLMServerNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) + +const promptRoot = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memoryNode, + testLLMServerNode, +]) function makeHttp() { - const deps = Layer.mergeAll( - Session.defaultLayer, - BackgroundJob.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer(), - ProviderSvc.defaultLayer, - lsp, - mcp, - FSUtil.defaultLayer, - SyncEvent.defaultLayer, - EventV2Bridge.defaultLayer, - Database.defaultLayer, - status, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(KiloSessions.testLayer), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provideMerge(deps), - ) - const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provideMerge(deps), - ), - ).pipe( - Layer.provide( - Layer.mergeAll( - summary, - deps, - Config.defaultLayer, - RuntimeFlags.layer(), - BackgroundJob.defaultLayer, - Bus.layer, - infra, - Storage.defaultLayer, - ), - ), - ) + return LayerNode.compile(promptRoot, [ + [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, mcp], + [KiloSessions.node, KiloSessions.testLayer], + ]) } - const it = testEffect(makeHttp()) const symlinkIt = process.platform === "win32" ? it.live.skip : it.live diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index ab599024013..1bd64ffc718 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -1,10 +1,11 @@ import path from "path" -import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test" import { Effect } from "effect" import fs from "fs/promises" import os from "os" import { Bus } from "../../src/bus" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" import { InstanceRef } from "../../src/effect/instance-ref" import { KiloSessionCompaction } from "@/kilocode/session/compaction" import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" @@ -12,6 +13,8 @@ import { KiloSession } from "@/kilocode/session" import { Suggestion } from "../../src/kilocode/suggestion" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { InstanceStore } from "../../src/project/instance-store" import { provideTestInstance } from "../fixture/fixture" import { Session } from "../../src/session/session" @@ -26,9 +29,13 @@ import { remove as cleanup } from "./cleanup" import { pollWithTimeout } from "../lib/effect" Log.init({ print: false }) +setDefaultTimeout(15_000) const previous = Flag.KILO_DB const dbfile = path.join(os.tmpdir(), `kilo-prompt-queue-${process.pid}-${crypto.randomUUID()}.db`) +const layer = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])) +const prompt = LayerNode.compile(LayerNode.group([SessionPrompt.node, SessionProjector.node])) +const runtime = makeRuntime(Session.Service, layer) beforeAll(async () => { await fs.rm(dbfile, { force: true }) @@ -36,6 +43,7 @@ beforeAll(async () => { }) afterAll(async () => { + await runtime.dispose() await AppRuntime.dispose() await disposeTestRuntime() Flag.KILO_DB = previous @@ -49,13 +57,13 @@ const store = { const sessions = { create: (input?: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.create(input)), messages: (input: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.messages(input)), updateMessage: (msg: T) => - Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.updateMessage(msg)), updatePart: (part: T) => - Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.updatePart(part)), } function line(input: unknown) { @@ -121,7 +129,7 @@ function hasText(msg: MessageV2.WithParts, text: string) { function scoped(dir: string, fn: (prompt: SessionPrompt.Interface) => Promise) { return Effect.runPromise( SessionPrompt.Service.use((prompt) => Effect.promise(() => fn(prompt))).pipe( - Effect.provide(SessionPrompt.defaultLayer), + Effect.provide(prompt), provideInstance(dir), Effect.provide(testInstanceStoreLayer), Effect.scoped, diff --git a/packages/opencode/test/kilocode/session-prompt-steering.test.ts b/packages/opencode/test/kilocode/session-prompt-steering.test.ts index dda61d2d024..1da8f07b4d2 100644 --- a/packages/opencode/test/kilocode/session-prompt-steering.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-steering.test.ts @@ -4,7 +4,10 @@ import fs from "fs/promises" import os from "os" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" import { MessageV2 } from "../../src/session/message-v2" import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" @@ -20,6 +23,9 @@ import { remove as cleanup } from "./cleanup" const previous = Flag.KILO_DB const dbfile = path.join(os.tmpdir(), `kilo-prompt-steering-${process.pid}-${crypto.randomUUID()}.db`) +const layer = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])) +const prompt = LayerNode.compile(LayerNode.group([SessionPrompt.node, SessionProjector.node])) +const runtime = makeRuntime(Session.Service, layer) beforeAll(async () => { await fs.rm(dbfile, { force: true }) @@ -27,6 +33,7 @@ beforeAll(async () => { }) afterAll(async () => { + await runtime.dispose() await AppRuntime.dispose() await disposeTestRuntime() Flag.KILO_DB = previous @@ -105,11 +112,9 @@ function question() { const sessions = { create: (input: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.create(input)), messages: (sessionID: SessionID) => - Effect.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID })).pipe(Effect.provide(Session.defaultLayer)), - ), + runtime.runPromise((svc) => svc.messages({ sessionID })), } async function wait(sessionID: SessionID) { @@ -130,7 +135,7 @@ async function wait(sessionID: SessionID) { function scoped(dir: string, fn: (prompt: SessionPrompt.Interface) => Promise) { return Effect.runPromise( SessionPrompt.Service.use((prompt) => Effect.promise(() => fn(prompt))).pipe( - Effect.provide(SessionPrompt.defaultLayer), + Effect.provide(prompt), provideInstance(dir), Effect.provide(testInstanceStoreLayer), Effect.scoped, diff --git a/packages/opencode/test/kilocode/session-resume-integration.test.ts b/packages/opencode/test/kilocode/session-resume-integration.test.ts index 9458dd4489f..ec2d6b600a7 100644 --- a/packages/opencode/test/kilocode/session-resume-integration.test.ts +++ b/packages/opencode/test/kilocode/session-resume-integration.test.ts @@ -3,16 +3,12 @@ import os from "node:os" import fs from "node:fs" import { expect } from "bun:test" import { Effect, Exit, Layer, Schema } from "effect" -import { FetchHttpClient } from "effect/unstable/http" -import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Agent as AgentSvc } from "../../src/agent/agent" -import { Auth } from "../../src/auth" import { BackgroundJob } from "../../src/background/job" -import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" @@ -36,13 +32,14 @@ import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { Session } from "../../src/session/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionStatus } from "../../src/session/status" import { SystemPrompt } from "../../src/session/system" import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" -import { SyncEvent } from "../../src/sync" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions" @@ -94,9 +91,11 @@ const mcp = Layer.succeed( MCP.Service.of({ status: () => Effect.succeed({}), clients: () => Effect.succeed({}), + instructions: () => Effect.succeed([]), tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, @@ -132,10 +131,6 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) - const cfg = { provider: { test: { @@ -181,163 +176,71 @@ function providerCfg(url: string) { } } -function makePrompt() { - const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - fastAgents, - Command.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - ProviderSvc.defaultLayer, - lsp, - mcp, - FSUtil.defaultLayer, - BackgroundJob.defaultLayer, - status, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - Bus.layer, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provide(Auth.defaultLayer), - Layer.provide(KiloSessions.testLayer), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - ) - const compact = SessionCompaction.layer.pipe( - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(proc), - Layer.provideMerge(deps), - ) - return SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - Layer.provide(summary), - ) -} +const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const serverNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) +const root = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memoryNode, + serverNode, +]) -// Picker tests need a Question service mock that auto-selects the first -// option instead of blocking on the question UI. -function makePromptWithAutoPick() { - const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - fastAgents, - Command.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - ProviderSvc.defaultLayer, - lsp, - mcp, - FSUtil.defaultLayer, - BackgroundJob.defaultLayer, - status, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - Bus.layer, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Layer.mock(Question.Service, { - ask: (input) => - Effect.gen(function* () { - const q = input.questions[0] - if (!q) return [] as readonly string[][] - const label = q.options?.[0]?.label ?? "" - return [[label]] - }), - }) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provide(Auth.defaultLayer), - Layer.provide(KiloSessions.testLayer), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - ) - const compact = SessionCompaction.layer.pipe( - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(proc), - Layer.provideMerge(deps), - ) - return SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - Layer.provide(summary), - ) -} +const replacements = [ + [SessionSummary.node, summary], + [AgentSvc.node, fastAgents], + [LSP.node, lsp], + [MCP.node, mcp], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], + [KiloSessions.node, KiloSessions.testLayer], +] as const -function makeHttp() { - return Layer.mergeAll(TestLLMServer.layer, makePrompt()) -} -const it = testEffect(makeHttp()) +const it = testEffect(LayerNode.compile(root, replacements)) -function makeHttpWithAutoPick() { - return Layer.mergeAll(TestLLMServer.layer, makePromptWithAutoPick()) -} -const itPicker = testEffect(makeHttpWithAutoPick()) +const picker = Layer.mock(Question.Service, { + ask: (input) => + Effect.gen(function* () { + const q = input.questions[0] + if (!q) return [] as readonly string[][] + const label = q.options?.[0]?.label ?? "" + return [[label]] + }), +}) +const itPicker = testEffect(LayerNode.compile(root, [...replacements, [Question.node, picker]])) const writeText = Effect.fn("test.writeText")(function* (file: string, text: string) { const fsys = yield* FSUtil.Service @@ -367,8 +270,7 @@ const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { // ── Claude fixture helpers ───────────────────────────────────────────── -const claudeFixture = () => - Bun.file(path.join(__dirname, "fixture/session-resume/claude.jsonl")).text() +const claudeFixture = () => Bun.file(path.join(__dirname, "fixture/session-resume/claude.jsonl")).text() const claudeInvalidVersion = `{"type":"user","version":"3.0.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}` @@ -494,7 +396,9 @@ it.instance( const tools = msgs.flatMap((msg) => msg.parts.filter((p) => p.type === "tool")) expect(tools.length).toBeGreaterThanOrEqual(4) - const completed = tools.filter((t) => (t as unknown as { state: { status: string } }).state.status === "completed") + const completed = tools.filter( + (t) => (t as unknown as { state: { status: string } }).state.status === "completed", + ) expect(completed.length).toBeGreaterThanOrEqual(2) const errors = tools.filter((t) => (t as unknown as { state: { status: string } }).state.status === "error") @@ -720,8 +624,7 @@ it.instance( // ── Codex fixture helpers ──────────────────────────────────────────────── -const codexFixture = () => - Bun.file(path.join(__dirname, "fixture/session-resume/codex.jsonl")).text() +const codexFixture = () => Bun.file(path.join(__dirname, "fixture/session-resume/codex.jsonl")).text() const withCodexFixture = (content: string, id: string) => Effect.acquireRelease( @@ -823,9 +726,7 @@ it.instance( const tools = msgs.flatMap((msg) => msg.parts.filter((p) => p.type === "tool")) expect(tools.length).toBeGreaterThanOrEqual(2) - const completed = tools.filter( - (t) => (t as { state: { status: string } }).state.status === "completed", - ) + const completed = tools.filter((t) => (t as { state: { status: string } }).state.status === "completed") expect(completed.length).toBeGreaterThanOrEqual(1) }), { config: cfg }, @@ -887,14 +788,14 @@ itPicker.instance( yield* withClaudeFixtureAt(roots.claude, dir, content, fixtureUUID) - yield* prompt.command({ - sessionID: chat.id, - command: "resume-claude", - arguments: "", - agent: "build", - }).pipe( - Effect.provideService(SessionResume.ResumeRoots, { claude: roots.claude }), - ) + yield* prompt + .command({ + sessionID: chat.id, + command: "resume-claude", + arguments: "", + agent: "build", + }) + .pipe(Effect.provideService(SessionResume.ResumeRoots, { claude: roots.claude })) const msgs = yield* sessionMessages(chat.id) expect(msgs.length).toBeGreaterThanOrEqual(10) @@ -920,14 +821,14 @@ itPicker.instance( yield* withCodexFixtureAt(roots.codex, content, fixtureUUID) - yield* prompt.command({ - sessionID: chat.id, - command: "resume-codex", - arguments: "", - agent: "build", - }).pipe( - Effect.provideService(SessionResume.ResumeRoots, { codex: roots.codex }), - ) + yield* prompt + .command({ + sessionID: chat.id, + command: "resume-codex", + arguments: "", + agent: "build", + }) + .pipe(Effect.provideService(SessionResume.ResumeRoots, { codex: roots.codex })) const msgs = yield* sessionMessages(chat.id) expect(msgs.length).toBeGreaterThanOrEqual(8) @@ -962,20 +863,21 @@ it.instance( Effect.sync(() => { fs.mkdirSync(file, { recursive: true }) }), - () => Effect.sync(() => { - fs.rmSync(path.dirname(file), { recursive: true, force: true }) - }), + () => + Effect.sync(() => { + fs.rmSync(path.dirname(file), { recursive: true, force: true }) + }), ) const exit = yield* Effect.exit( - prompt.command({ - sessionID: chat.id, - command: "resume-claude", - arguments: fixtureUUID, - agent: "build", - }).pipe( - Effect.provideService(SessionResume.ResumeRoots, { claude: root }), - ), + prompt + .command({ + sessionID: chat.id, + command: "resume-claude", + arguments: fixtureUUID, + agent: "build", + }) + .pipe(Effect.provideService(SessionResume.ResumeRoots, { claude: root })), ) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/kilocode/session-share.test.ts b/packages/opencode/test/kilocode/session-share.test.ts index 4f83bee74d7..3ed665b9ff4 100644 --- a/packages/opencode/test/kilocode/session-share.test.ts +++ b/packages/opencode/test/kilocode/session-share.test.ts @@ -1,23 +1,27 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { expect, spyOn } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Auth } from "../../src/auth" -import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Session } from "../../src/session/session" import { SessionShare } from "../../src/share/session" import { Storage } from "../../src/storage/storage" -import { SyncEvent } from "../../src/sync" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll(Auth.defaultLayer, Storage.defaultLayer, CrossSpawnSpawner.defaultLayer, RuntimeFlags.layer()), -) - -const layer = SessionShare.layer.pipe( - Layer.provideMerge(Session.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), + LayerNode.compile( + LayerNode.group([ + SessionShare.node, + Session.node, + SessionProjector.node, + Auth.node, + Storage.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + ]), + ), ) it.instance("shares and unshares sessions through Kilo public URLs", () => { @@ -61,6 +65,5 @@ it.instance("shares and unshares sessions through Kilo public URLs", () => { request.mockRestore() }), ), - Effect.provide(layer), ) }) diff --git a/packages/opencode/test/kilocode/session/instruction-substitution.test.ts b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts index 7578d5b7ead..dc68f4dc135 100644 --- a/packages/opencode/test/kilocode/session/instruction-substitution.test.ts +++ b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import path from "node:path" import { Effect, FileSystem, Layer } from "effect" @@ -12,20 +13,19 @@ import { Global } from "@opencode-ai/core/global" import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" import { TestConfig } from "../../fixture/config" +import { Config } from "../../../src/config/config" const it = testEffect( - Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer, RuntimeFlags.layer()), + Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), NodeFileSystem.layer, testInstanceStoreLayer, RuntimeFlags.layer()), ) const configLayer = TestConfig.layer() const layer = (dir: string, config = configLayer) => - Instruction.layer.pipe( - Layer.provide(config), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Global.layerWith({ home: dir, config: dir })), - ) + AppNodeBuilder.build(Instruction.node, [ + [Config.node, config], + [Global.node, Global.layerWith({ home: dir, config: dir })], + ]) const write = (filepath: string, content: string) => Effect.gen(function* () { diff --git a/packages/opencode/test/kilocode/session/revert.test.ts b/packages/opencode/test/kilocode/session/revert.test.ts index a2274bb55c9..a0716e1fd3c 100644 --- a/packages/opencode/test/kilocode/session/revert.test.ts +++ b/packages/opencode/test/kilocode/session/revert.test.ts @@ -1,5 +1,7 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import { Effect, Exit } from "effect" import fs from "node:fs/promises" import path from "node:path" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -14,11 +16,14 @@ import { Snapshot } from "@/snapshot" import { provideInstance, provideTmpdirInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" -const env = Layer.mergeAll( - Session.defaultLayer, - SessionRevert.defaultLayer, - Snapshot.defaultLayer, - CrossSpawnSpawner.defaultLayer, +const env = LayerNode.compile( + LayerNode.group([ + Session.node, + SessionProjector.node, + SessionRevert.node, + Snapshot.node, + CrossSpawnSpawner.node, + ]), ) const it = testEffect(env) const guarded = process.platform === "win32" ? it.live.skip : it.live diff --git a/packages/opencode/test/kilocode/session/transcript.test.ts b/packages/opencode/test/kilocode/session/transcript.test.ts index 4d1fa18ccc7..2dc30786b1c 100644 --- a/packages/opencode/test/kilocode/session/transcript.test.ts +++ b/packages/opencode/test/kilocode/session/transcript.test.ts @@ -1,5 +1,7 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -9,7 +11,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema" import { provideTmpdirInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" -const env = Layer.mergeAll(Session.defaultLayer, CrossSpawnSpawner.defaultLayer) +const env = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, CrossSpawnSpawner.node])) const it = testEffect(env) const providerID = ProviderV2.ID.make("test") diff --git a/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts b/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts index 02f41b61d15..f34051df620 100644 --- a/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts +++ b/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts @@ -1,8 +1,8 @@ import { expect } from "bun:test" -import { NodeFileSystem } from "@effect/platform-node" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { Database } from "@opencode-ai/core/database/database" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -11,7 +11,6 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import { MemoryService } from "@kilocode/kilo-memory/effect/service" import { Deferred, Effect, Fiber, Layer } from "effect" import * as Stream from "effect/Stream" -import { FetchHttpClient } from "effect/unstable/http" import path from "path" import { Agent as AgentSvc } from "../../../src/agent/agent" import { Auth } from "../../../src/auth" @@ -24,7 +23,11 @@ import { EventV2Bridge } from "../../../src/event-v2-bridge" import { Format } from "../../../src/format" import { Git } from "../../../src/git" import { Image } from "../../../src/image/image" -import { clearAll as clearRenameMarks, consumeAutoTitle, markAutoTitle } from "../../../src/kilo-sessions/rename-adoptions" +import { + clearAll as clearRenameMarks, + consumeAutoTitle, + markAutoTitle, +} from "../../../src/kilo-sessions/rename-adoptions" import { KiloSessions } from "../../../src/kilo-sessions/kilo-sessions" import { LSP } from "../../../src/lsp/lsp" import { MCP } from "../../../src/mcp" @@ -40,6 +43,7 @@ import { SessionPrompt } from "../../../src/session/prompt" import { SessionRevert } from "../../../src/session/revert" import { SessionRunState } from "../../../src/session/run-state" import { Session } from "../../../src/session/session" +import { MessageV2 } from "../../../src/session/message-v2" import { SessionStatus } from "../../../src/session/status" import { SessionSummary } from "../../../src/session/summary" import { SystemPrompt } from "../../../src/session/system" @@ -52,6 +56,7 @@ import { RuntimeFlags } from "../../../src/effect/runtime-flags" import { TestInstance } from "../../fixture/fixture" import { pollWithTimeout, testEffect } from "../../lib/effect" import { TestLLMServer } from "../../lib/llm-server" +import { SessionProjector } from "@opencode-ai/core/session/projector" // Drives the real SessionPrompt.ensureTitle path (forked on loop step 1) for: // - mid-generation non-default skip (re-check before mark/setTitle) @@ -70,6 +75,8 @@ const mcp = Layer.succeed( tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + instructions: () => Effect.succeed([]), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, @@ -114,10 +121,6 @@ const summary = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) - /** Shared mutable hooks for ensureTitle integration tests. */ const hooks = { stallTitle: undefined as Deferred.Deferred | undefined, @@ -126,120 +129,93 @@ const hooks = { setTitleCalls: [] as { sessionID: string; title: string }[], } -const llmWrapped = Layer.effect( - LLM.Service, - Effect.gen(function* () { - const inner = yield* LLM.Service - return { - stream: (input: Parameters[0]) => { - if (input.agent.name === "title" && hooks.stallTitle) { - hooks.titleStreamEntered = true - const gate = hooks.stallTitle - return Stream.unwrap( - Effect.gen(function* () { - yield* Deferred.await(gate) - return inner.stream(input) - }), - ) - } - return inner.stream(input) - }, - } satisfies LLM.Interface - }), -).pipe(Layer.provide(LLM.defaultLayer)) +const memory = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const server = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) +const root = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memory, + server, +]) +const env = LayerNode.compile(root, [ + [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, mcp], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], + [KiloSessions.node, KiloSessions.testLayer], +]) +const it = testEffect(env) -const sessionWrapped = Layer.effect( - Session.Service, - Effect.gen(function* () { - const inner = yield* Session.Service - return { - ...inner, - setTitle: (input: { sessionID: Session.Info["id"]; title: string }) => - Effect.gen(function* () { - hooks.setTitleCalls.push(input) - if (hooks.failSetTitle) { - // Mark must already be present (mark-before-write). Consume proves it, - // then re-mark so ensureTitle's catchCause still has something to clear. - expect(consumeAutoTitle(input.sessionID, input.title)).toBe(true) - markAutoTitle(input.sessionID, input.title) - return yield* Effect.die(new Error("setTitle failed for test")) - } - return yield* inner.setTitle(input) - }), - } as Session.Interface - }), -).pipe(Layer.provide(Session.defaultLayer)) +const installHooks = Effect.fn("test.installTitleHooks")(function* () { + const llm = yield* LLM.Service + const sessions = yield* Session.Service + const stream = llm.stream + const title = sessions.setTitle + const mutableLLM = llm as { stream: LLM.Interface["stream"] } + const mutableSession = sessions as { setTitle: Session.Interface["setTitle"] } -function makePrompt() { - const deps = Layer.mergeAll( - sessionWrapped, - Snapshot.defaultLayer, - llmWrapped, - Env.defaultLayer, - AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - ProviderSvc.defaultLayer, - lsp, - mcp, - FSUtil.defaultLayer, - BackgroundJob.defaultLayer, - status, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - Bus.layer, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provide(Auth.defaultLayer), - Layer.provide(KiloSessions.testLayer), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - ) - const compact = SessionCompaction.layer.pipe( - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(proc), - Layer.provideMerge(deps), - ) - return SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - Layer.provide(summary), - ) -} + mutableLLM.stream = (input) => { + if (input.agent.name !== "title" || !hooks.stallTitle) return stream(input) + hooks.titleStreamEntered = true + const gate = hooks.stallTitle + return Stream.unwrap( + Effect.gen(function* () { + yield* Deferred.await(gate) + return stream(input) + }), + ) + } + mutableSession.setTitle = (input) => + Effect.gen(function* () { + hooks.setTitleCalls.push(input) + if (!hooks.failSetTitle) return yield* title(input) + expect(consumeAutoTitle(input.sessionID, input.title)).toBe(true) + markAutoTitle(input.sessionID, input.title) + return yield* Effect.die(new Error("setTitle failed for test")) + }) -const it = testEffect(Layer.mergeAll(TestLLMServer.layer, makePrompt())) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + mutableLLM.stream = stream + mutableSession.setTitle = title + }), + ) +}) function providerCfg(url: string): Partial { return { @@ -323,6 +299,7 @@ it.instance( () => Effect.gen(function* () { resetHooks() + yield* installHooks() const { llm } = yield* useServerConfig() const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service @@ -375,6 +352,7 @@ it.instance( () => Effect.gen(function* () { resetHooks() + yield* installHooks() const { llm } = yield* useServerConfig() const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service @@ -423,6 +401,7 @@ it.instance( () => Effect.gen(function* () { resetHooks() + yield* installHooks() const { llm } = yield* useServerConfig() const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service diff --git a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts index d5c407b1aba..265074cd8c4 100644 --- a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts +++ b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, expect, spyOn } from "bun:test" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Auth } from "../../../src/auth" import { Bus } from "../../../src/bus" import { GlobalBus } from "../../../src/bus/global" -import type { Config } from "../../../src/config/config" +import { Config } from "../../../src/config/config" import { clearInFlightCache } from "../../../src/kilo-sessions/inflight-cache" import { clearAll as clearRenameMarks, @@ -16,6 +18,7 @@ import { import { Session } from "../../../src/session/session" import { SessionID } from "../../../src/session/schema" import { ProjectV2 } from "@opencode-ai/core/project" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { TestConfig } from "../../fixture/config" import { pollWithTimeout, testEffect } from "../../lib/effect" import { TestInstance } from "../../fixture/fixture" @@ -24,13 +27,12 @@ const KiloSessions = (await import("../../../src/kilo-sessions/kilo-sessions")). // Session must be provideMerged so yield* Session.Service and the // KiloSessions Updated handler share one store (otherwise get() misses creates). -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Auth.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), AppNodeBuilder.build(Auth.node))) function layer(overrides: Partial = {}) { - return KiloSessions.layer.pipe( - Layer.provideMerge(Bus.layer), - Layer.provideMerge(Session.defaultLayer), - Layer.provide(TestConfig.layer(overrides)), + return AppNodeBuilder.build( + LayerNode.group([KiloSessions.node, Session.node, SessionProjector.node]), + [[Config.node, TestConfig.layer(overrides)]], ) } diff --git a/packages/opencode/test/kilocode/sessions/send-agent-notification.test.ts b/packages/opencode/test/kilocode/sessions/send-agent-notification.test.ts index 8b9a09002e8..3e3c21b82c3 100644 --- a/packages/opencode/test/kilocode/sessions/send-agent-notification.test.ts +++ b/packages/opencode/test/kilocode/sessions/send-agent-notification.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect, spyOn, beforeEach, afterEach } from "bun:test" import { Effect, Fiber, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -28,17 +29,17 @@ afterEach(() => { } }) -const it = testEffect(CrossSpawnSpawner.defaultLayer) -const multi = testEffect(Layer.merge(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer)) +const it = testEffect(AppNodeBuilder.build(CrossSpawnSpawner.node)) +const multi = testEffect(Layer.merge(AppNodeBuilder.build(CrossSpawnSpawner.node), testInstanceStoreLayer)) function layer(overrides: Partial = {}) { return Layer.merge( KiloSessions.layer.pipe( Layer.provideMerge(Bus.layer), Layer.provide(TestConfig.layer(overrides)), - Layer.provide(Session.defaultLayer), + Layer.provide(AppNodeBuilder.build(Session.node)), ), - Auth.defaultLayer, + AppNodeBuilder.build(Auth.node), ) } diff --git a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts index 7ef70b045a1..53f77bc8f3c 100644 --- a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts +++ b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import path from "path" @@ -6,7 +7,7 @@ import { Command } from "../../src/command" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Command.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Command.node), AppNodeBuilder.build(CrossSpawnSpawner.node))) describe("skill slash commands", () => { it.live("lists and resolves skills that conflict with commands", () => diff --git a/packages/opencode/test/kilocode/skills/inject.test.ts b/packages/opencode/test/kilocode/skills/inject.test.ts index e3fbb53db95..d267fd18350 100644 --- a/packages/opencode/test/kilocode/skills/inject.test.ts +++ b/packages/opencode/test/kilocode/skills/inject.test.ts @@ -1,6 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Ripgrep } from "@opencode-ai/core/ripgrep" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Shell } from "@opencode-ai/core/shell" import { Effect, Exit, Layer } from "effect" import { afterEach, describe, expect } from "bun:test" @@ -36,7 +36,7 @@ afterEach(async () => { }) const it = testEffect( - Layer.mergeAll(ToolRegistry.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)), + Layer.mergeAll(AppNodeBuilder.build(ToolRegistry.node), AppNodeBuilder.build(CrossSpawnSpawner.node)), ) // Shell injection spawns real processes; skip on windows CI like the sibling suite. diff --git a/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts b/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts index a80bc95344c..5d7de81a2e5 100644 --- a/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts +++ b/packages/opencode/test/kilocode/snapshot-freeze-repro.test.ts @@ -12,6 +12,7 @@ // 3. A concurrent setInterval keeps ticking — i.e. the event loop keeps // breathing and ESC would be delivered. +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { test, expect, afterEach, mock } from "bun:test" import { $ } from "bun" import { Effect, Fiber, Layer } from "effect" @@ -33,8 +34,8 @@ function run(ctx: InstanceContext, body: (snapshot: Snapshot.Interface) => Ef return Effect.runPromise( seedProject.pipe( Effect.andThen(Snapshot.Service.use(body)), - Effect.provide(Snapshot.defaultLayer), - Effect.provide(Session.defaultLayer.pipe(Layer.provideMerge(Database.defaultLayer))), + Effect.provide(AppNodeBuilder.build(Snapshot.node)), + Effect.provide(AppNodeBuilder.build(Session.node).pipe(Layer.provideMerge(AppNodeBuilder.build(Database.node)))), Effect.provideService(InstanceRef, ctx), ), ) diff --git a/packages/opencode/test/kilocode/snapshot-seed.test.ts b/packages/opencode/test/kilocode/snapshot-seed.test.ts index 3506cb9c22a..92d61582d95 100644 --- a/packages/opencode/test/kilocode/snapshot-seed.test.ts +++ b/packages/opencode/test/kilocode/snapshot-seed.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, expect, test } from "bun:test" import { $ } from "bun" import fs from "fs/promises" @@ -48,7 +49,7 @@ function durable(snapshot: Snapshot.Interface) { }) } -const infra = Layer.mergeAll(AppProcess.defaultLayer, FSUtil.defaultLayer) +const infra = Layer.mergeAll(AppNodeBuilder.build(AppProcess.node), AppNodeBuilder.build(FSUtil.node)) function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effect) { return Effect.runPromise( @@ -57,7 +58,7 @@ function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effe const value = yield* body(snapshot) const gitdir = path.join(Global.Path.data, "snapshot", Instance.project.id, Hash.fast(Instance.worktree)) return { value, gitdir } - }).pipe(provideInstance(dir), Effect.provide(Snapshot.defaultLayer), Effect.provide(testInstanceStoreLayer)), + }).pipe(provideInstance(dir), Effect.provide(AppNodeBuilder.build(Snapshot.node)), Effect.provide(testInstanceStoreLayer)), ) } diff --git a/packages/opencode/test/kilocode/snowflake-cortex-provider.test.ts b/packages/opencode/test/kilocode/snowflake-cortex-provider.test.ts index 85ade34248d..19c4e8172a1 100644 --- a/packages/opencode/test/kilocode/snowflake-cortex-provider.test.ts +++ b/packages/opencode/test/kilocode/snowflake-cortex-provider.test.ts @@ -1,16 +1,16 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { expect } from "bun:test" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" import { Provider } from "@/provider/provider" -import { InstanceLayer } from "@/project/instance-layer" import { Env } from "@/env" import { Plugin } from "@/plugin" -import { provideInstanceEffect, tmpdirScoped } from "../fixture/fixture" +import { provideInstanceEffect, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(AppNodeBuilder.build(Provider.node), AppNodeBuilder.build(Env.node), AppNodeBuilder.build(Plugin.node), AppNodeBuilder.build(CrossSpawnSpawner.node)), ) it.effect("loads Snowflake Cortex from OAuth credentials", () => @@ -49,7 +49,7 @@ it.effect("loads Snowflake Cortex from OAuth credentials", () => }) const provider = yield* Provider.use .getProvider(ProviderV2.ID.make("snowflake-cortex")) - .pipe(provideInstanceEffect(directory), Effect.provide(InstanceLayer.layer)) + .pipe(provideInstanceEffect(directory), Effect.provide(testInstanceStoreLayer)) expect(provider.options.baseURL).toBe("https://test-account.snowflakecomputing.com/api/v2/cortex/v1") expect(provider.options.apiKey).toBe("access-token") diff --git a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts index 790b8d7567b..a95d5547ca2 100644 --- a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts +++ b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts @@ -3,8 +3,10 @@ // tool propagates each child session's total cost up to the parent's // tool-wrapper assistant message (#6321). +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Database } from "@opencode-ai/core/database/database" import { aggregateSessionStats } from "../../src/cli/cmd/stats" import { MessageV2 } from "../../src/session/message-v2" @@ -17,7 +19,7 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, Database.node]))) const ref = { providerID: ProviderV2.ID.make("test"), diff --git a/packages/opencode/test/kilocode/suggestion/tool.test.ts b/packages/opencode/test/kilocode/suggestion/tool.test.ts index a7182938940..0e367332e21 100644 --- a/packages/opencode/test/kilocode/suggestion/tool.test.ts +++ b/packages/opencode/test/kilocode/suggestion/tool.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, beforeEach, describe, expect, spyOn } from "bun:test" import { Effect, Fiber, Layer } from "effect" import { Command } from "../../../src/command" @@ -31,7 +32,7 @@ const status = Layer.succeed( set: (sessionID, value) => Effect.sync(() => statuses.push([sessionID, value])), }), ) -const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer, command, status)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Truncate.node), AppNodeBuilder.build(Agent.node), command, status)) const init = Effect.fn("SuggestToolTest.init")(function* () { const info = yield* SuggestTool diff --git a/packages/opencode/test/kilocode/sync-event-encoding.test.ts b/packages/opencode/test/kilocode/sync-event-encoding.test.ts index 8faba9f41a6..d1802f9bc55 100644 --- a/packages/opencode/test/kilocode/sync-event-encoding.test.ts +++ b/packages/opencode/test/kilocode/sync-event-encoding.test.ts @@ -1,10 +1,11 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect, test } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Database as CoreDatabase } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionMessageID } from "@opencode-ai/core/session/message-id" +import { SessionMessage } from "@opencode-ai/core/session/message" import { DateTime, Deferred, Effect, Layer, Schema } from "effect" import { GlobalBus } from "../../src/bus/global" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -16,7 +17,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll(EventV2Bridge.defaultLayer, CoreDatabase.defaultLayer, CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(AppNodeBuilder.build(EventV2Bridge.node), AppNodeBuilder.build(CoreDatabase.node), AppNodeBuilder.build(CrossSpawnSpawner.node)), ) afterEach(resetDatabase) @@ -57,7 +58,7 @@ describe("SyncEvent encoding", () => { yield* events.publish(SessionEvent.Text.Ended, { sessionID, timestamp: DateTime.makeUnsafe(1_234), - assistantMessageID: SessionMessageID.ID.create(), + assistantMessageID: SessionMessage.ID.create(), textID: "text_event_bus", text: "hello", }) @@ -85,7 +86,7 @@ describe("SyncEvent encoding", () => { yield* events.publish(SessionEvent.Text.Ended, { sessionID, timestamp, - assistantMessageID: SessionMessageID.ID.create(), + assistantMessageID: SessionMessage.ID.create(), textID: "text_event_replay", text: "hello", }) diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index d63a6316707..4d566155130 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -1,5 +1,7 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterEach, describe, expect, test } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import { Effect, Exit } from "effect" import { Database } from "@opencode-ai/core/database/database" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "../../src/background/job" @@ -34,20 +36,23 @@ const ref = { } const it = testEffect( - Layer.mergeAll( - Agent.defaultLayer, - BackgroundJob.defaultLayer, - Bus.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer(), - SessionRunState.defaultLayer, - SessionStatus.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Session.defaultLayer, - Truncate.defaultLayer, - Provider.defaultLayer, - ToolRegistry.defaultLayer, - Database.defaultLayer, + LayerNode.compile( + LayerNode.group([ + Agent.node, + BackgroundJob.node, + Bus.node, + Config.node, + RuntimeFlags.node, + SessionRunState.node, + SessionStatus.node, + CrossSpawnSpawner.node, + Session.node, + SessionProjector.node, + Truncate.node, + Provider.node, + ToolRegistry.node, + Database.node, + ]), ), ) diff --git a/packages/opencode/test/kilocode/tool-encoding.test.ts b/packages/opencode/test/kilocode/tool-encoding.test.ts index 07f0d1d898e..df9f02017d4 100644 --- a/packages/opencode/test/kilocode/tool-encoding.test.ts +++ b/packages/opencode/test/kilocode/tool-encoding.test.ts @@ -3,6 +3,7 @@ // Tests exercise the real tool pipeline rather than the Encoding helper // directly so we validate end-to-end behaviour. +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect } from "bun:test" import { Effect, Exit, Layer } from "effect" import path from "path" @@ -45,15 +46,15 @@ afterEach(async () => { const it = testEffect( Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Instruction.defaultLayer, - LSP.defaultLayer, + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(Instruction.node), + AppNodeBuilder.build(LSP.node), Bus.layer, - Format.defaultLayer, - Truncate.defaultLayer, - EventV2Bridge.defaultLayer, + AppNodeBuilder.build(Format.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(EventV2Bridge.node), ), ) diff --git a/packages/opencode/test/kilocode/tool-registry-apply-patch.test.ts b/packages/opencode/test/kilocode/tool-registry-apply-patch.test.ts index 53425a3c588..55a3afd27e2 100644 --- a/packages/opencode/test/kilocode/tool-registry-apply-patch.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-apply-patch.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" @@ -9,8 +10,8 @@ import { ToolRegistry } from "../../src/tool/registry" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(Agent.defaultLayer, ToolRegistry.defaultLayer, node)) +const node = AppNodeBuilder.build(CrossSpawnSpawner.node) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Agent.node), AppNodeBuilder.build(ToolRegistry.node), node)) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index db247c7c63a..54d34cd192d 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect, spyOn, test } from "bun:test" import { Effect, Layer, Schema, Stream } from "effect" import * as Log from "@opencode-ai/core/util/log" @@ -22,8 +23,8 @@ import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" -const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(Agent.defaultLayer, ToolRegistry.defaultLayer, node)) +const node = AppNodeBuilder.build(CrossSpawnSpawner.node) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Agent.node), AppNodeBuilder.build(ToolRegistry.node), node)) const ref = { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test-model"), diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index 1cf1350e120..62ee105a839 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -1,5 +1,7 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterEach, beforeAll, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Database } from "@opencode-ai/core/database/database" import fs from "fs/promises" import path from "path" @@ -99,20 +101,23 @@ const catalog = { } const it = testEffect( - Layer.mergeAll( - Agent.defaultLayer, - BackgroundJob.defaultLayer, - Bus.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer(), - SessionRunState.defaultLayer, - SessionStatus.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Session.defaultLayer, - Truncate.defaultLayer, - Provider.defaultLayer, - ToolRegistry.defaultLayer, - Database.defaultLayer, + LayerNode.compile( + LayerNode.group([ + Agent.node, + BackgroundJob.node, + Bus.node, + Config.node, + RuntimeFlags.node, + SessionRunState.node, + SessionStatus.node, + CrossSpawnSpawner.node, + Session.node, + SessionProjector.node, + Truncate.node, + Provider.node, + ToolRegistry.node, + Database.node, + ]), ), ) diff --git a/packages/opencode/test/kilocode/tool/repo-overview.test.ts b/packages/opencode/test/kilocode/tool/repo-overview.test.ts index f0b43ee45f8..5ddd333a577 100644 --- a/packages/opencode/test/kilocode/tool/repo-overview.test.ts +++ b/packages/opencode/test/kilocode/tool/repo-overview.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect } from "bun:test" import path from "path" import { Cause, Effect, Exit, Layer } from "effect" @@ -29,11 +30,11 @@ const ctx = { const it = testEffect( Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Git.defaultLayer, - Truncate.defaultLayer, + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(Git.node), + AppNodeBuilder.build(Truncate.node), ), ) diff --git a/packages/opencode/test/kilocode/tool/repo_clone.test.ts b/packages/opencode/test/kilocode/tool/repo_clone.test.ts index 330fc8c63b0..a225c18c761 100644 --- a/packages/opencode/test/kilocode/tool/repo_clone.test.ts +++ b/packages/opencode/test/kilocode/tool/repo_clone.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect } from "bun:test" import path from "path" import { pathToFileURL } from "node:url" @@ -31,12 +32,12 @@ const ctx = { const it = testEffect( Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Git.defaultLayer, - RepositoryCache.defaultLayer, - Truncate.defaultLayer, + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(Git.node), + AppNodeBuilder.build(RepositoryCache.node), + AppNodeBuilder.build(Truncate.node), ), ) diff --git a/packages/opencode/test/kilocode/tool/shell-signal.test.ts b/packages/opencode/test/kilocode/tool/shell-signal.test.ts index e62ab3ce540..f6a41e72310 100644 --- a/packages/opencode/test/kilocode/tool/shell-signal.test.ts +++ b/packages/opencode/test/kilocode/tool/shell-signal.test.ts @@ -9,7 +9,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import path from "path" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/core/fs-util" import { Agent } from "../../../src/agent/agent" import { Config } from "../../../src/config/config" @@ -22,13 +23,13 @@ import { provideInstance, testInstanceStoreLayer } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" const layer = Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - RuntimeFlags.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Plugin.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Config.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(RuntimeFlags.node), testInstanceStoreLayer, ) const it = testEffect(layer) diff --git a/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts b/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts index 88ad0e57d08..7044c078aa0 100644 --- a/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts +++ b/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts @@ -9,6 +9,7 @@ // recovered nothing falls back to the raw command text (also covering ERROR // chunks without a command_name descendant, such as backtick escapes). +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" import { Cause, Effect, Exit, Layer } from "effect" import path from "path" @@ -28,7 +29,7 @@ import { SessionID, MessageID } from "../../../src/session/schema" import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdir } from "../../fixture/fixture" import { afterEach } from "bun:test" -const layer = Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer) +const layer = Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), AppNodeBuilder.build(FSUtil.node), testInstanceStoreLayer) type ScanRequest = Omit @@ -155,13 +156,13 @@ describe("shell permission scanner fails closed on unparsed commands", () => { }) const execLayer = Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - RuntimeFlags.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Plugin.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Config.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(RuntimeFlags.node), testInstanceStoreLayer, ) diff --git a/packages/opencode/test/kilocode/tui-bootstrap.test.ts b/packages/opencode/test/kilocode/tui-bootstrap.test.ts new file mode 100644 index 00000000000..65856431ff7 --- /dev/null +++ b/packages/opencode/test/kilocode/tui-bootstrap.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, test } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2" +import { Server } from "../../src/server/server" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +test("blocking TUI bootstrap requests complete", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const client = createKiloClient({ + baseUrl: "http://kilo.internal", + directory: tmp.path, + fetch: ((request: RequestInfo | URL, init?: RequestInit) => + Server.Default().app.fetch(new Request(request, init))) as typeof fetch, + }) + + const responses = await Promise.all([ + client.config.providers({}, { throwOnError: true }), + client.provider.list({}, { throwOnError: true }), + client.experimental.capabilities.get({}, { throwOnError: true }), + client.app.agents({}, { throwOnError: true }), + client.config.get({}, { throwOnError: true }), + client.global.config.get({ throwOnError: true }), + ]) + + for (const response of responses) expect(response.data).toBeDefined() +}) diff --git a/packages/opencode/test/kilocode/tui/sync-v2.test.tsx b/packages/opencode/test/kilocode/tui/sync-v2.test.tsx index 64503fa8383..0b3c7414b7b 100644 --- a/packages/opencode/test/kilocode/tui/sync-v2.test.tsx +++ b/packages/opencode/test/kilocode/tui/sync-v2.test.tsx @@ -205,13 +205,13 @@ test("sync v2 renders admitted prompts only after promotion", async () => { emitTwice(events, { id: "evt_promoted_1", - type: "session.next.prompt.promoted", + type: "session.next.prompted", properties: { sessionID: "session-1", messageID: "msg_user_1", timestamp: 1, prompt: { text: "hello" }, - timeCreated: 0, + delivery: "steer", }, }) @@ -254,13 +254,13 @@ test("sync v2 renders a promoted prompt when admission was missed", async () => await mounted emitTwice(events, { id: "evt_promoted_1", - type: "session.next.prompt.promoted", + type: "session.next.prompted", properties: { sessionID: "session-1", messageID: "msg_user_1", timestamp: 1, prompt: { text: "hello" }, - timeCreated: 0, + delivery: "steer", }, }) @@ -401,13 +401,13 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows", await mounted emitTwice(events, { id: "evt_promoted_1", - type: "session.next.prompt.promoted", + type: "session.next.prompted", properties: { sessionID: "session-1", messageID: "msg_user_1", timestamp: 1, prompt: { text: "stale" }, - timeCreated: 0, + delivery: "steer", }, }) await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1") diff --git a/packages/opencode/test/kilocode/worktree-family-submodule.test.ts b/packages/opencode/test/kilocode/worktree-family-submodule.test.ts index 1f7be1e4bd1..263d5470faa 100644 --- a/packages/opencode/test/kilocode/worktree-family-submodule.test.ts +++ b/packages/opencode/test/kilocode/worktree-family-submodule.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { $ } from "bun" import { describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -14,7 +15,7 @@ import { testEffect } from "../lib/effect" Log.init({ print: false }) const it = testEffect( - Layer.mergeAll(Project.defaultLayer, Git.defaultLayer, CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer), + Layer.mergeAll(AppNodeBuilder.build(Project.node), AppNodeBuilder.build(Git.node), AppNodeBuilder.build(CrossSpawnSpawner.node), testInstanceStoreLayer), ) describe("WorktreeFamily.list — git submodule", () => { diff --git a/packages/opencode/test/kilocode/worktree-family.test.ts b/packages/opencode/test/kilocode/worktree-family.test.ts index abd6d4e369f..70e5efc1ec5 100644 --- a/packages/opencode/test/kilocode/worktree-family.test.ts +++ b/packages/opencode/test/kilocode/worktree-family.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer } from "effect" @@ -9,7 +10,7 @@ import { resetDatabase } from "../fixture/db" import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Project.defaultLayer, Git.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Project.node), AppNodeBuilder.build(Git.node), AppNodeBuilder.build(CrossSpawnSpawner.node))) describe("WorktreeFamily.list", () => { it.live("returns recorded sandboxes when git worktree listing fails", () => diff --git a/packages/opencode/test/kilocode/worktree-project-skills.test.ts b/packages/opencode/test/kilocode/worktree-project-skills.test.ts index 367680158ed..24b2220feee 100644 --- a/packages/opencode/test/kilocode/worktree-project-skills.test.ts +++ b/packages/opencode/test/kilocode/worktree-project-skills.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { $ } from "bun" import { afterEach, describe, expect } from "bun:test" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -15,17 +16,10 @@ import { Discovery } from "../../src/skill/discovery" import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const layer = Skill.layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.layer({ disableExternalSkills: false, disableClaudeCodeSkills: false })), - Layer.provide(EventV2Bridge.defaultLayer), -) -const it = testEffect(Layer.mergeAll(layer, CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer)) +const layer = AppNodeBuilder.build(Skill.node, [ + [RuntimeFlags.node, RuntimeFlags.layer({ disableExternalSkills: false, disableClaudeCodeSkills: false })], +]) +const it = testEffect(Layer.mergeAll(layer, AppNodeBuilder.build(CrossSpawnSpawner.node), testInstanceStoreLayer)) afterEach(() => disposeAllInstances()) diff --git a/packages/opencode/test/kilocode/worktree-remove-lock.test.ts b/packages/opencode/test/kilocode/worktree-remove-lock.test.ts index 820c9cb3cc5..3ee09b0e593 100644 --- a/packages/opencode/test/kilocode/worktree-remove-lock.test.ts +++ b/packages/opencode/test/kilocode/worktree-remove-lock.test.ts @@ -1,14 +1,23 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { $ } from "bun" import { describe, expect } from "bun:test" import * as fs from "fs/promises" import path from "path" import { Effect, Layer } from "effect" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const bootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const it = testEffect( + Layer.mergeAll( + AppNodeBuilder.build(Worktree.node, [[InstanceStore.bootstrapNode, bootstrap]]), + AppNodeBuilder.build(CrossSpawnSpawner.node), + ), +) describe("Worktree.remove lock retries", () => { it.live("retries transient git remove lock failures", () => diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 0c6384d613a..cd9b3c3a97b 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -19,8 +19,10 @@ // different return shape — see the TODO at the bottom of OpencodeCli. import { test, type TestOptions } from "bun:test" import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppProcess } from "@opencode-ai/core/process" -import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect" +import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcess } from "effect/unstable/process" import path from "node:path" @@ -88,6 +90,11 @@ export type RunResult = { readonly durationMs: number } +export type RunHandle = { + readonly interrupt: () => void + readonly result: Effect.Effect +} + export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record } // Typed equivalent of constructing argv for `opencode run`. New flags should @@ -98,6 +105,7 @@ export type RunOpts = SpawnOpts & { readonly format?: "default" | "json" readonly command?: string readonly printLogs?: boolean + readonly permission?: Record readonly extraArgs?: string[] } @@ -153,6 +161,7 @@ export type AcpHandle = { export type OpencodeCli = { // High-level: run a single prompt against the test model. Short-lived. readonly run: (message: string, opts?: RunOpts) => Effect.Effect + readonly startRun: (message: string, opts?: RunOpts) => Effect.Effect // Spawn `opencode serve` and wait until it's listening. Long-lived: the // returned handle is killed when the caller's Scope closes. Fails if the // listening line doesn't appear within `readyTimeoutMs`. @@ -191,16 +200,19 @@ export function withCliFixture( const fs = yield* FSUtil.Service const appProc = yield* AppProcess.Service - // FileSystem.makeTempDirectoryScoped handles both creation and scope-tied - // cleanup — replaces the old mkdir + addFinalizer pair. - const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" }) + const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" }) + yield* Effect.addFinalizer(() => + fs + .remove(home, { recursive: true }) + .pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore), + ) const configJson = JSON.stringify(testProviderConfig(llm.url)) const env = isolatedEnv(home, configJson) const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) { const start = Date.now() - const timeoutMs = opts?.timeoutMs ?? 30_000 + const timeoutMs = opts?.timeoutMs ?? 45_000 // kilocode_change - current full CLI startup leaves less than 30s for multi-step runs // stdin: "ignore" so the child doesn't see a piped stdin and block // on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is // consumed as the prompt). The old Process.run wrapper defaulted to @@ -237,13 +249,13 @@ export function withCliFixture( ) return { exitCode: result.exitCode, - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), + stdout: normalizeLines(result.stdout.toString()), + stderr: normalizeLines(result.stderr.toString()), durationMs: Date.now() - start, } }) - const run = (message: string, opts?: RunOpts): Effect.Effect => { + const runArgs = (message: string, opts?: RunOpts) => { const argv: string[] = ["run"] if (opts?.printLogs) argv.push("--print-logs") argv.push("--model", opts?.model ?? testModelID) @@ -252,9 +264,61 @@ export function withCliFixture( if (opts?.command) argv.push("--command", opts.command) if (opts?.extraArgs) argv.push(...opts.extraArgs) argv.push(message) - return spawn(argv, opts) + return argv } + const runOpts = (opts?: RunOpts): SpawnOpts | undefined => { + if (!opts?.permission) return opts + return { + ...opts, + env: { + ...opts.env, + KILO_CONFIG_CONTENT: JSON.stringify({ + ...testProviderConfig(llm.url), + permission: opts.permission, + }), + }, + } + } + + const run = (message: string, opts?: RunOpts): Effect.Effect => { + return spawn(runArgs(message, opts), runOpts(opts)) + } + + const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) { + const start = Date.now() + const options = runOpts(opts) + const proc = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.spawn(["bun", ...cliArgs, ...runArgs(message, opts)], { + // kilocode_change - cliArgs carries the solid preload + cwd: home, + env: { ...process.env, ...env, ...options?.env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ), + (child) => + Effect.promise(() => { + child.kill() + return child.exited + }).pipe(Effect.ignore), + ) + const stdout = new Response(proc.stdout).text() + const stderr = new Response(proc.stderr).text() + + return { + interrupt: () => proc.kill("SIGINT"), + result: Effect.promise(async () => ({ + exitCode: await proc.exited, + stdout: normalizeLines(await stdout), + stderr: normalizeLines(await stderr), + durationMs: Date.now() - start, + })), + } satisfies RunHandle + }) + const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) { const argv = ["serve"] // Default port 0 — let the OS pick a free port, parse the actual one @@ -410,14 +474,18 @@ export function withCliFixture( } satisfies AcpHandle }) - const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents } + const opencode: OpencodeCli = { run, startRun, serve, acp, spawn, expectExit, parseJsonEvents } return yield* fn({ llm, home, opencode }) // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient` // and hit endpoints on `opencode.serve()` without rolling their own fetch. }).pipe( Effect.provide( - Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer, FSUtil.defaultLayer, AppProcess.defaultLayer), + Layer.mergeAll( + TestLLMServer.layer, + FetchHttpClient.layer, + AppNodeBuilder.build(LayerNode.group([FSUtil.node, AppProcess.node])), + ), ), ) } @@ -430,6 +498,10 @@ function parseJsonEvents(stdout: string): Array> { .map((line) => JSON.parse(line) as Record) } +function normalizeLines(value: string) { + return value.replaceAll("\r\n", "\n") +} + // Convenience for the common assertion pattern. Dumps stderr/stdout when // the exit code doesn't match — saves debugging time on CI failures. function expectExit(result: RunResult, expected: number, label = "opencode") { @@ -465,8 +537,8 @@ export const cliIt = { body: (input: CliFixture) => Effect.Effect, opts?: number | TestOptions, ) => - // kilocode_change start - Windows CI cannot reliably start nested CLI trees concurrently - (process.platform === "win32" ? test : test.concurrent)( + // kilocode_change start - full CLI processes contend heavily during startup after the Effect graph migration + test.serial( name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts, diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 561a195e813..d272622a1ab 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,6 +1,8 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, spyOn, test } from "bun:test" import path from "path" import fs from "fs/promises" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer } from "effect" import { EventV2Bridge } from "@/event-v2-bridge" import { Config } from "@/config/config" @@ -20,19 +22,17 @@ const fakeCtx = {} as InstanceContext const fakeFlags = {} as RuntimeFlags.Info const lspLayer = (flags: Parameters[0] = {}) => - LSP.layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), - Layer.provideMerge(EventV2Bridge.defaultLayer), - ) + LayerNode.compile(LayerNode.group([LSP.node, Config.node, RuntimeFlags.node, EventV2Bridge.node]), [ + [RuntimeFlags.node, RuntimeFlags.layer(flags)], + ]) -const it = testEffect(Layer.mergeAll(lspLayer(), CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(lspLayer(), LayerNode.compile(CrossSpawnSpawner.node))) const experimentalTyIt = testEffect( - Layer.mergeAll(lspLayer({ experimentalLspTy: true }), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(lspLayer({ experimentalLspTy: true }), LayerNode.compile(CrossSpawnSpawner.node)), ) const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") const disabledDownloadIt = testEffect( - Layer.mergeAll(lspLayer({ disableLspDownload: true }), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(lspLayer({ disableLspDownload: true }), LayerNode.compile(CrossSpawnSpawner.node)), ) describe("lsp.spawn", () => { @@ -82,7 +82,7 @@ describe("lsp.spawn", () => { // kilocode_change start - provide the runtime flag so spawn() is reached past the TsClient short-circuit const experimentalToolIt = testEffect( - Layer.mergeAll(lspLayer({ experimentalLspTool: true }), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(lspLayer({ experimentalLspTool: true }), AppNodeBuilder.build(CrossSpawnSpawner.node)), ) experimentalToolIt.live("would spawn builtin LSP for files inside instance when lsp is true", () => diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index 5d0313e6d20..17387a6bf82 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { LSP } from "@/lsp/lsp" import * as LSPServer from "@/lsp/server" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LSP.node)) describe("LSP service lifecycle", () => { let spawnSpy: ReturnType diff --git a/packages/opencode/test/mcp/auth.test.ts b/packages/opencode/test/mcp/auth.test.ts index 0fdfe78b2af..5aa099983a3 100644 --- a/packages/opencode/test/mcp/auth.test.ts +++ b/packages/opencode/test/mcp/auth.test.ts @@ -1,8 +1,8 @@ import { expect, test } from "bun:test" import { setTimeout as sleep } from "node:timers/promises" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { McpAuth } from "../../src/mcp/auth" function authFile() { @@ -10,7 +10,7 @@ function authFile() { let activeWrites = 0 let sawOverlap = false - const layer = Layer.effect( + const fsLayer = Layer.effect( FSUtil.Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -41,14 +41,14 @@ function authFile() { : fs.writeJson(file, value, mode), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(AppNodeBuilder.build(FSUtil.node))) - return { layer, raw: () => raw } + return { fsLayer, raw: () => raw } } -function authService(layer: Layer.Layer) { +function authService(fsLayer: Layer.Layer) { return McpAuth.Service.use((auth) => Effect.succeed(auth)).pipe( - Effect.provide(McpAuth.layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(layer))), + Effect.provide(AppNodeBuilder.build(McpAuth.node, [[FSUtil.node, fsLayer]])), ) } @@ -57,8 +57,8 @@ test("serializes concurrent auth file updates across service instances", async ( await Effect.runPromise( Effect.gen(function* () { - const first = yield* authService(file.layer) - const second = yield* authService(file.layer) + const first = yield* authService(file.fsLayer) + const second = yield* authService(file.fsLayer) yield* Effect.all( [ diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts new file mode 100644 index 00000000000..55cabaef769 --- /dev/null +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { McpCatalog } from "@/mcp/catalog" + +const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any + +function clientReturning(result: unknown) { + return { + callTool: async () => result, + } as unknown as Client +} + +function mcpTool() { + return { + name: "screenshot", + description: "Take a screenshot", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + } as any +} + +describe("McpCatalog.convertTool", () => { + test("preserves content when structuredContent is also present", async () => { + const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] + const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent })) + + const output = await converted.execute?.({}, options) + + expect(output).toMatchObject({ content, structuredContent }) + }) + + test("falls back to structuredContent only when content is absent", async () => { + const structuredContent = { results: [{ title: "one" }] } + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent })) + + const output = await converted.execute?.({}, options) + + expect(output).toMatchObject({ + structuredContent, + content: [{ type: "text", text: JSON.stringify(structuredContent) }], + }) + }) +}) diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index c51ed00d32f..e6b83d678ec 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, mock, beforeEach } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { testEffect } from "../lib/effect" @@ -46,7 +47,7 @@ beforeEach(() => { // Import MCP after mocking const { MCP } = await import("../../src/mcp/index") -const it = testEffect(MCP.defaultLayer) +const it = testEffect(LayerNode.compile(MCP.node)) describe("mcp.headers", () => { it.instance("headers are passed to transports when oauth is enabled (default)", () => diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index eb17a62dbcf..55c5a3dcbb7 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -2,6 +2,7 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { expect, mock, beforeEach } from "bun:test" import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" @@ -15,10 +16,12 @@ import { TestInstance } from "../fixture/fixture" interface MockClientState { capabilities: { tools?: object; prompts?: object; resources?: object } capabilitiesShouldThrow: boolean + instructions?: string tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number listPromptsCalls: number listResourcesCalls: number + listResourceTemplatesCalls: number getPromptTimeout?: number readResourceTimeout?: number requestCalls: number @@ -28,6 +31,7 @@ interface MockClientState { listResourcesShouldFail: boolean prompts: Array<{ name: string; description?: string }> resources: Array<{ name: string; uri: string; description?: string }> + resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }> toolPages: Record< string, { @@ -40,6 +44,10 @@ interface MockClientState { string, { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } > + resourceTemplatePages: Record< + string, + { resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string } + > closed: boolean clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } } requestHandlers: Map Promise> @@ -69,6 +77,7 @@ function getOrCreateClientState(name?: string): MockClientState { listToolsCalls: 0, listPromptsCalls: 0, listResourcesCalls: 0, + listResourceTemplatesCalls: 0, requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", @@ -76,9 +85,11 @@ function getOrCreateClientState(name?: string): MockClientState { listResourcesShouldFail: false, prompts: [], resources: [], + resourceTemplates: [], toolPages: {}, promptPages: {}, resourcePages: {}, + resourceTemplatePages: {}, closed: false, requestHandlers: new Map(), notificationHandlers: new Map(), @@ -181,6 +192,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return this._state?.capabilities } + getInstructions() { + return this._state?.instructions + } + async listTools(params?: { cursor?: string }) { if (this._state) this._state.listToolsCalls++ if (this._state?.listToolsShouldFail) { @@ -226,6 +241,13 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { resources: this._state?.resources ?? [] } } + async listResourceTemplates(params?: { cursor?: string }) { + if (this._state) this._state.listResourceTemplatesCalls++ + const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")] + if (page) return page + return { resourceTemplates: this._state?.resourceTemplates ?? [] } + } + async getPrompt(_params: unknown, options?: { timeout?: number }) { if (this._state) this._state.getPromptTimeout = options?.timeout return { messages: [] } @@ -256,7 +278,7 @@ beforeEach(() => { const { MCP } = await import("../../src/mcp/index") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") -const it = testEffect(MCP.defaultLayer) +const it = testEffect(LayerNode.compile(MCP.node)) // kilocode_change start function sandboxProfile(): Profile { @@ -400,6 +422,60 @@ it.instance( { config: { mcp: {} } }, ) +it.instance( + "instructions() returns connected server instructions with tool names", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "guide-server" + const serverState = getOrCreateClientState("guide-server") + serverState.instructions = "Use lookup before mutate." + + yield* mcp.add("guide-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(yield* mcp.instructions()).toContainEqual({ + name: "guide-server", + instructions: "Use lookup before mutate.", + tools: ["guide-server_test_tool"], + }) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "instructions() omits empty and disconnected server instructions", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "temporary-server" + getOrCreateClientState("temporary-server").instructions = "Temporary guidance." + + yield* mcp.add("temporary-server", { + type: "local", + command: ["echo", "test"], + }) + yield* mcp.disconnect("temporary-server") + + lastCreatedClientName = "blank-server" + getOrCreateClientState("blank-server").instructions = " " + + yield* mcp.add("blank-server", { + type: "local", + command: ["echo", "test"], + }) + + const instructions = yield* mcp.instructions() + expect(instructions.some((item) => item.name === "temporary-server")).toBe(false) + expect(instructions.some((item) => item.name === "blank-server")).toBe(false) + }), + ), + { config: { mcp: {} } }, +) + it.instance( "follows cursors when listing tools, prompts, and resources", () => @@ -422,6 +498,13 @@ it.instance( initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] }, } + serverState.resourceTemplatePages = { + initial: { + resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }], + nextCursor: "resource-templates-2", + }, + "resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] }, + } yield* mcp.add("paged-server", { type: "local", @@ -430,10 +513,15 @@ it.instance( expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) - expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"]) + expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([ + "paged-server:test://one/{id}", + "paged-server:test://two/{id}", + ]) expect(serverState.listToolsCalls).toBe(2) expect(serverState.listPromptsCalls).toBe(2) expect(serverState.listResourcesCalls).toBe(2) + expect(serverState.listResourceTemplatesCalls).toBe(2) }), ), { config: { mcp: {} } }, @@ -865,7 +953,10 @@ it.instance( Effect.gen(function* () { lastCreatedClientName = "resource-server" const serverState = getOrCreateClientState("resource-server") - serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }] + serverState.resources = [ + { name: "my-resource", uri: "file:///test.txt", description: "A test resource" }, + { name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" }, + ] yield* mcp.add("resource-server", { type: "local", @@ -873,10 +964,10 @@ it.instance( }) const resources = yield* mcp.resources() - expect(Object.keys(resources).length).toBe(1) - const key = Object.keys(resources)[0] - expect(key).toContain("resource-server") - expect(key).toContain("my-resource") + expect(Object.keys(resources)).toEqual([ + "resource-server:file:///test.txt", + "resource-server:ui://component-state", + ]) }), ), { @@ -932,7 +1023,7 @@ it.instance( expect(statusName(result.status, "resource-only-server")).toBe("connected") expect(serverState.listToolsCalls).toBe(0) expect(Object.keys(yield* mcp.tools())).toHaveLength(0) - expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"]) expect(serverState.listResourcesCalls).toBe(1) expect(serverState.listPromptsCalls).toBe(0) }), diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 9b46853c790..febbe0d0bdc 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,4 +1,5 @@ import { expect, mock, beforeEach } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { testEffect } from "../lib/effect" @@ -23,6 +24,8 @@ let simulateAuthFlow = true let connectSucceedsImmediately = false let serverCapabilities: { tools?: object; resources?: object } = { tools: {} } let listToolsCalls = 0 +let finishAuthFails = false +let finishAuthStoresCredentials = false // Mock the transport constructors to simulate OAuth auto-auth on 401 void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ @@ -32,6 +35,10 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ state?: () => Promise redirectToAuthorization?: (url: URL) => Promise saveCodeVerifier?: (v: string) => Promise + tokens?: () => Promise<{ access_token: string } | undefined> + clientInformation?: () => Promise<{ client_id: string } | undefined> + saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise + saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise } | undefined constructor(url: URL, options?: { authProvider?: unknown }) { @@ -49,6 +56,8 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ // It calls auth() which eventually calls provider.state(), then // provider.redirectToAuthorization(), then throws UnauthorizedError. if (simulateAuthFlow && this.authProvider) { + if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError() + if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError() // The SDK calls provider.state() to get the OAuth state parameter if (this.authProvider.state) { await this.authProvider.state() @@ -65,7 +74,14 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ } throw new MockUnauthorizedError() } - async finishAuth(_code: string) {} + async finishAuth(_code: string) { + if (finishAuthFails) throw new Error("Token exchange failed") + if (finishAuthStoresCredentials) { + await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" }) + await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" }) + } + } + async close() {} }, })) @@ -99,6 +115,8 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return serverCapabilities } + getInstructions() {} + async listTools() { listToolsCalls++ return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] } @@ -123,6 +141,8 @@ beforeEach(() => { connectSucceedsImmediately = false serverCapabilities = { tools: {} } listToolsCalls = 0 + finishAuthFails = false + finishAuthStoresCredentials = false }) // Import modules after mocking @@ -131,19 +151,13 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Config } = await import("../../src/config/config") const { McpAuth } = await import("../../src/mcp/auth") const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") +const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { FSUtil } = await import("@opencode-ai/core/fs-util") const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") const mcpTest = testEffect( - Layer.mergeAll( - MCP.layer.pipe( - Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - ), - McpAuth.defaultLayer, + LayerNode.compile( + LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), ), ) @@ -225,6 +239,83 @@ mcpTest.instance("state() returns existing state when one is saved", () => }), ) +mcpTest.instance( + "failed reauthentication preserves existing credentials", + () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + const name = "test-reauth-failure" + const url = "https://example.com/mcp" + const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" } + + yield* auth.updateClientInfo(name, clientInfo, url) + yield* auth.updateTokens(name, { accessToken: "working-token" }, url) + expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize") + finishAuthFails = true + + expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({ + status: "failed", + error: "OAuth completion failed: Token exchange failed", + }) + const entry = yield* auth.get(name) + expect(entry?.tokens?.accessToken).toBe("working-token") + expect(entry?.clientInfo).toEqual(clientInfo) + }), + { config: config("test-reauth-failure") }, +) + +mcpTest.instance( + "successful reauthentication commits replacement credentials", + () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + const name = "test-reauth-success" + const url = "https://example.com/mcp" + + yield* auth.updateClientInfo(name, { clientId: "old-client" }, url) + yield* auth.updateTokens(name, { accessToken: "old-token" }, url) + expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize") + expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token") + finishAuthStoresCredentials = true + connectSucceedsImmediately = true + + expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected") + const entry = yield* auth.get(name) + expect(entry?.tokens?.accessToken).toBe("replacement-token") + expect(entry?.clientInfo?.clientId).toBe("replacement-client") + expect(entry?.serverUrl).toBe(url) + }), + { config: config("test-reauth-success") }, +) + +mcpTest.instance( + "auth status only reports credentials stored for the configured server URL", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + expect(transportCalls).toHaveLength(0) + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp") + + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated") + + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp") + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated") + + yield* McpAuth.use.updateTokens( + "test-status-url", + { accessToken: "expired-token", expiresAt: 1 }, + "https://example.com/mcp", + ) + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired") + expect(transportCalls).toHaveLength(0) + }), + { config: config("test-status-url") }, +) + mcpTest.instance( "authenticate() stores a connected client when auth completes without redirect", () => @@ -269,7 +360,7 @@ mcpTest.instance( const result = yield* mcp.authenticate("test-oauth-resources") expect(result.status).toBe("connected") expect(listToolsCalls).toBe(0) - expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"]) }), ), { config: config("test-oauth-resources") }, diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 7af29241571..cccb8b4967b 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,5 +1,6 @@ import { expect, mock, beforeEach } from "bun:test" import { EventEmitter } from "events" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Option } from "effect" import { awaitWithTimeout, testEffect } from "../lib/effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -128,12 +129,8 @@ const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { FSUtil } = await import("@opencode-ai/core/fs-util") const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") const mcpTest = testEffect( - MCP.layer.pipe( - Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), + LayerNode.compile( + LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), ), ) const service = MCP.Service as unknown as Effect.Effect @@ -169,10 +166,10 @@ const trackBrowserOpenFailed = Effect.gen(function* () { return event }) -const authenticateScoped = (name: string) => +const authenticateScoped = (name: string, onAuthorization?: (authorizationUrl: string) => void) => Effect.gen(function* () { const mcp = yield* service - yield* mcp.authenticate(name).pipe( + yield* mcp.authenticate(name, onAuthorization).pipe( Effect.ignore, Effect.catchCause(() => Effect.void), Effect.forkScoped, @@ -231,12 +228,19 @@ mcpTest.instance( const opened = yield* trackBrowserOpen const event = yield* trackBrowserOpenFailed - yield* authenticateScoped("test-oauth-server-3") + const authorization = yield* Deferred.make() + yield* authenticateScoped("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url))) const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds") + const authorizationUrl = yield* awaitWithTimeout( + Deferred.await(authorization), + "Timed out waiting for authorization URL", + "5 seconds", + ) const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) expect(failure).toEqual(Option.none()) + expect(authorizationUrl).toBe(url) expect(typeof url).toBe("string") expect(url).toContain("https://") expect(transportCalls.at(-1)?.options.requestInit?.headers).toEqual({ "X-Custom-Header": "custom-value" }) diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index cac7146580d..1666a37142b 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -1,7 +1,41 @@ import { test, expect, describe, afterEach } from "bun:test" +import { createConnection, createServer as createNetServer } from "net" import { McpOAuthCallback } from "../../src/mcp/oauth-callback" import { parseRedirectUri } from "../../src/mcp/oauth-provider" +async function getFreeLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const probe = createNetServer() + probe.once("error", reject) + probe.listen(0, "127.0.0.1", () => { + const address = probe.address() + probe.close(() => { + if (typeof address === "object" && address) { + resolve(address.port) + return + } + reject(new Error("Could not allocate a loopback port")) + }) + }) + }) +} + +async function canConnect(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host, port }) + const done = (ok: boolean) => { + socket.removeAllListeners() + socket.destroy() + resolve(ok) + } + + socket.setTimeout(500) + socket.once("connect", () => done(true)) + socket.once("error", () => done(false)) + socket.once("timeout", () => done(false)) + }) +} + describe("parseRedirectUri", () => { test("returns defaults when no URI provided", () => { const result = parseRedirectUri() @@ -67,6 +101,14 @@ describe("McpOAuthCallback.ensureRunning", () => { `${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`, ) - expect(await response.text()).toContain('
The user denied access
') + expect(await response.text()).toContain('
The user denied access
') + }) + + test("binds the callback server to IPv4 loopback", async () => { + const port = await getFreeLoopbackPort() + await McpOAuthCallback.ensureRunning(`http://127.0.0.1:${port}/custom/callback`) + + expect(await canConnect("127.0.0.1", port)).toBe(true) + expect(await canConnect("::1", port)).toBe(false) }) }) diff --git a/packages/opencode/test/mcp/oauth-provider.test.ts b/packages/opencode/test/mcp/oauth-provider.test.ts index 64c2cb66877..249c49e8f91 100644 --- a/packages/opencode/test/mcp/oauth-provider.test.ts +++ b/packages/opencode/test/mcp/oauth-provider.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe } from "bun:test" +import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js" import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider" import type { McpAuth } from "../../src/mcp/auth" @@ -59,3 +60,43 @@ describe("McpOAuthProvider.clientMetadata", () => { expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none") }) }) + +describe("MCP OAuth scope selection", () => { + test("adds offline_access when the authorization server and client support refresh tokens", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read", "offline_access"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read offline_access") + }) + + test("does not add unsupported authorization server scopes", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read") + }) +}) diff --git a/packages/opencode/test/patch/patch.test.ts b/packages/opencode/test/patch/patch.test.ts index c1e47a4f1d5..0a8dd5d8ad0 100644 --- a/packages/opencode/test/patch/patch.test.ts +++ b/packages/opencode/test/patch/patch.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import * as fs from "fs/promises" import * as path from "path" @@ -7,7 +8,7 @@ import { Patch } from "../../src/patch" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" -const it = testEffect(FSUtil.defaultLayer) +const it = testEffect(LayerNode.compile(FSUtil.node)) describe("Patch namespace", () => { let tempDir: string diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index e5d92c5815d..09a71465d5c 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,11 +1,12 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, test, expect } from "bun:test" import { Effect } from "effect" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { testEffect } from "./lib/effect" -const it = testEffect(Config.defaultLayer) +const it = testEffect(LayerNode.compile(Config.node)) const load = Config.use.get() diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index c1670ecde51..ae20488a842 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { test, expect } from "bun:test" import os from "os" @@ -14,14 +15,14 @@ import { MessageID, SessionID } from "../../src/session/schema" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Config } from "../../src/config/config" -const events = EventV2Bridge.defaultLayer +const events = AppNodeBuilder.build(EventV2Bridge.node) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const env = Layer.mergeAll( - Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(events)), + AppNodeBuilder.build(Permission.node), events, - CrossSpawnSpawner.defaultLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), -).pipe(Layer.provide(RuntimeFlags.layer()), Layer.provide(Config.defaultLayer)) + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]), // kilocode_change +).pipe(Layer.provide(RuntimeFlags.layer()), Layer.provide(AppNodeBuilder.build(Config.node))) const it = testEffect(Layer.mergeAll(env, RuntimeFlags.layer())) const rejectAll = (message?: string) => diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 1f841c1b4e0..6cff029d04d 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,48 +1,41 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" -import { Plugin } from "@/plugin" import { RuntimeFlags } from "@/effect/runtime-flags" -import { Auth } from "@/auth" -import { ModelCache } from "@/provider/model-cache" // kilocode_change -import { EventV2Bridge } from "@/event-v2-bridge" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Config } from "@/config/config" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node]))) -function layer(directory: string, plugins: string[]) { - return ProviderAuth.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(ModelCache.defaultLayer), // kilocode_change - Layer.provide( - Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(RuntimeFlags.layer()), - Layer.provide( - TestConfig.layer({ - get: () => - Effect.succeed({ - plugin: plugins, - plugin_origins: plugins.map((plugin) => ({ - spec: plugin, - source: path.join(directory, "opencode.json"), - scope: "local" as const, - })), - }), - directories: () => Effect.succeed([directory]), +function providerAuthLayer(directory: string, plugins: string[]) { + return AppNodeBuilder.build(ProviderAuth.node, [ + [ + Config.node, + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ + spec: plugin, + source: path.join(directory, "opencode.json"), + scope: "local" as const, + })), }), - ), - ), - ), - ) + directories: () => Effect.succeed([directory]), + }), + ], + [RuntimeFlags.node, RuntimeFlags.layer()], + ]) } describe("plugin.auth-override", () => { @@ -75,10 +68,12 @@ describe("plugin.auth-override", () => { const plain = yield* tmpdirScoped({ git: true }) const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href - const methods = yield* ProviderAuth.use.methods().pipe(Effect.provide(layer(tmp.directory, [plugin]))) + const methods = yield* ProviderAuth.use + .methods() + .pipe(Effect.provide(providerAuthLayer(tmp.directory, [plugin]))) const plainMethods = yield* ProviderAuth.use .methods() - .pipe(Effect.provide(layer(plain, [])), provideInstance(plain)) + .pipe(Effect.provide(providerAuthLayer(plain, [])), provideInstance(plain)) const copilot = methods[ProviderV2.ID.make("github-copilot")] expect(copilot).toBeDefined() diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 017bc84d851..005a6e78cb5 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,17 +1,18 @@ import { afterEach, describe, expect, spyOn } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Config } from "@/config/config" import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") -const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Npm } = await import("@opencode-ai/core/npm") const { TestConfig } = await import("../fixture/config") const { RuntimeFlags } = await import("../../src/effect/runtime-flags") @@ -20,7 +21,9 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer), +) function withTmp( init: (dir: string) => Promise, @@ -45,10 +48,9 @@ function load(dir: string, flags?: Parameters[0]) { yield* plugin.list() }).pipe( Effect.provide( - Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })), - Layer.provide( + LayerNode.compile(Plugin.node, [ + [ + Config.node, TestConfig.layer({ get: () => Effect.succeed({ @@ -57,8 +59,9 @@ function load(dir: string, flags?: Parameters[0]) { }), directories: () => Effect.succeed([dir]), }), - ), - ), + ], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })], + ]), ), provideInstance(dir), ) diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index d1d1198f1a0..ca3a6c2ab2f 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -559,9 +559,11 @@ describe("plugin.openai.ws-pool", () => { }) test("retries failed websocket streams before using HTTP fallback", async () => { + const attempts: Array<(socket: WebSocket) => void> = [] await using server = await createWebSocketServer((socket) => { socket.once("message", () => { socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" })) + attempts.shift()?.(socket) }) }) const fetch = OpenAIWebSocketPool.createWebSocketFetch({ @@ -570,10 +572,16 @@ describe("plugin.openai.ws-pool", () => { streamRetries: 1, }) + const firstAttempt = new Promise((resolve) => attempts.push(resolve)) const first = await fetch(server.url, streamRequest()) - expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket") + const firstSocket = await firstAttempt + firstSocket.terminate() + expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed") + const secondAttempt = new Promise((resolve) => attempts.push(resolve)) const second = await fetch(server.url, streamRequest()) - expect((await readTextError(second.text())).message).toContain("idle timeout waiting for websocket") + const secondSocket = await secondAttempt + secondSocket.terminate() + expect((await readTextError(second.text())).message).toContain("WebSocket closed before response.completed") const third = await fetch(server.url, streamRequest()) expect(await third.text()).toBe("http") diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index d4a50bd5962..24450e97708 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" @@ -20,25 +21,22 @@ import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { Auth } from "../../src/auth" +import { Account } from "../../src/account/account" +import { Npm } from "@opencode-ai/core/npm" -const configLayer = Config.layer.pipe( - Layer.provide(Git.defaultLayer), // kilocode_change - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), -) +const configLayer = AppNodeBuilder.build(Config.node, [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], +]) const it = testEffect( Layer.mergeAll( - Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(configLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), - ), - CrossSpawnSpawner.defaultLayer, + AppNodeBuilder.build(Plugin.node, [ + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), + AppNodeBuilder.build(CrossSpawnSpawner.node), ), ) const systemHook = "experimental.chat.system.transform" diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 8276a350849..bb820fd16b8 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" @@ -17,6 +18,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Workspace } from "../../src/control-plane/workspace" import { Plugin } from "../../src/plugin/index" import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap as InstanceBootstrapNode } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Vcs } from "../../src/project/vcs" @@ -28,38 +30,25 @@ import { testEffect } from "../lib/effect" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" +import { Account } from "../../src/account/account" +import { Npm } from "@opencode-ai/core/npm" -const configLayer = Config.layer.pipe( - Layer.provide(Git.defaultLayer), // kilocode_change - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), -) -const pluginLayer = Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(configLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), -) +const configLayer = AppNodeBuilder.build(Config.node, [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], +]) +const pluginLayer = AppNodeBuilder.build(Plugin.node, [ + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], +]) const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const workspaceLayer = Workspace.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })), -) +const workspaceLayer = AppNodeBuilder.build(Workspace.node, [ + [InstanceBootstrapNode.node, noopBootstrapLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: true })], +]) const it = testEffect( - Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)), + Layer.mergeAll(pluginLayer, workspaceLayer, AppNodeBuilder.build(CrossSpawnSpawner.node)).pipe(Layer.provide(AppNodeBuilder.build(Ripgrep.node))), ) afterEach(async () => { diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts index 5009d6b500b..f855024e5dc 100644 --- a/packages/opencode/test/project/instance-bootstrap.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -2,16 +2,21 @@ import { afterEach, expect } from "bun:test" import { existsSync } from "node:fs" import path from "node:path" import { pathToFileURL } from "node:url" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Effect, Exit, Fiber } from "effect" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" -import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { waitGlobalBusEvent } from "../server/global-bus" -const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + LayerNode.compile(LayerNode.group([InstanceStore.node, CrossSpawnSpawner.node]), [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], + ]), +) // InstanceBootstrap must run before any code touches the instance — // originally tracked by PRs #25389 and #25449, now a permanent diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 383ad0208f6..9c920341333 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,9 +1,10 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Deferred, Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { capture } from "../../src/kilocode/instance" // kilocode_change import { tmpdirScoped } from "../fixture/fixture" @@ -16,7 +17,9 @@ const noopBootstrap = Layer.succeed( ) const it = testEffect( - Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), + LayerNode.compile(LayerNode.group([InstanceStore.node, CrossSpawnSpawner.node]), [ + [InstanceStore.bootstrapNode, noopBootstrap], + ]), ) const setBootstrap = (run: Effect.Effect) => diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 77de870161d..24d8aaaaaf6 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -9,11 +9,12 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { SessionID } from "../../src/session/schema" import { $ } from "bun" import { tmpdirScoped } from "../fixture/fixture" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, Database.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]))) function legacySessionID() { // Global-session migration covers persisted IDs from before prefixed session IDs. diff --git a/packages/opencode/test/project/project-directory.test.ts b/packages/opencode/test/project/project-directory.test.ts index cb458c30f33..112271c2c47 100644 --- a/packages/opencode/test/project/project-directory.test.ts +++ b/packages/opencode/test/project/project-directory.test.ts @@ -2,8 +2,9 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import path from "path" import { eq } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { Hash } from "@opencode-ai/core/util/hash" import { AbsolutePath } from "@opencode-ai/core/schema" import { Database } from "@opencode-ai/core/database/database" @@ -13,7 +14,7 @@ import { Project } from "@/project/project" import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]))) function directories(projectID: ProjectV2.ID) { return Database.Service.use(({ db }) => diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 1f645ab250d..9f94d3dba8e 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,5 +1,4 @@ import { describe, expect } from "bun:test" -import { EventV2Bridge } from "@/event-v2-bridge" import { Project } from "@/project/project" import { $ } from "bun" import path from "path" @@ -15,19 +14,17 @@ import { SessionID } from "@/session/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Cause, Effect, Exit, Layer, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { NodePath } from "@effect/platform-node" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { AppProcess } from "@opencode-ai/core/process" import { ProjectV2 } from "@opencode-ai/core/project" -import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const encoder = new TextEncoder() -const layer = Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer) -const it = testEffect(layer) +const projectTestNode = LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]) +const it = testEffect(AppNodeBuilder.build(projectTestNode)) function remoteProjectID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) @@ -65,41 +62,37 @@ function mockGitFailure(failArg: string) { }), ) }), - ).pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) + ).pipe(Layer.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) } function projectLayerWithFailure(failArg: string) { - return Project.layer.pipe( - Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))), - Layer.provide(mockGitFailure(failArg)), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - ) + return AppNodeBuilder.build(Project.node, [ + [ProjectV2.node, projectV2FailureLayer()], + [CrossSpawnSpawner.node, mockGitFailure(failArg)], + ]) } -function projectLayerWithRuntimeFlags(flags: Parameters[0]) { - return Project.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), +function projectV2FailureLayer() { + return Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + directories: () => Effect.succeed([]), + resolve: (input) => + Effect.succeed({ + id: ProjectV2.ID.global, + directory: input, + vcs: { type: "git" as const, store: input }, + }), + commit: () => Effect.void, + }), ) } const failureIt = (failArg: string) => - testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) + testEffect(AppNodeBuilder.build(projectTestNode, [[Project.node, projectLayerWithFailure(failArg)]])) const iconDiscoveryIt = testEffect( - Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer), + AppNodeBuilder.build(projectTestNode, [[RuntimeFlags.node, RuntimeFlags.layer({ experimentalIconDiscovery: true })]]), ) function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect { diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 21620adaf02..c13b108bc6a 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,8 +1,9 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { parsePatch } from "diff" import { Deferred, Effect, Layer } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" import { @@ -24,10 +25,8 @@ import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -const layer = Layer.mergeAll( - Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer)), - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, +const layer = LayerNode.compile( + LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, FSUtil.node, CrossSpawnSpawner.node]), ) const it = testEffect(layer) const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer)) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index c7175780248..9e061f6d883 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -2,13 +2,15 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import * as fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(Worktree.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])) const wintest = process.platform === "win32" ? it.instance : it.instance.skip describe("Worktree.remove", () => { diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index eebd0f55bde..e2a7e747232 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -1,16 +1,20 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll(Worktree.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer), + LayerNode.compile(LayerNode.group([Worktree.node, FSUtil.node, Git.node]), [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], + ]), ) const wintest = process.platform !== "win32" ? it.instance : it.instance.skip diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index d1cc510cd1a..6e677631c40 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import path from "path" import { unlink } from "fs/promises" import { Global } from "@opencode-ai/core/global" @@ -12,7 +13,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node]))) const originalEnv = new Map() diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts index ca15fdfe038..34d7decabb4 100644 --- a/packages/opencode/test/provider/digitalocean.test.ts +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Provider } from "../../src/provider/provider" import { Effect } from "effect" @@ -6,7 +7,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" const DIGITALOCEAN = ProviderV2.ID.make("digitalocean") -const it = testEffect(Provider.defaultLayer) +const it = testEffect(LayerNode.compile(Provider.node)) const withEnv = (values: Record, effect: Effect.Effect) => Effect.acquireUseRelease( diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index 14b7997965e..03320118fce 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -1,8 +1,9 @@ import { afterEach, expect } from "bun:test" import { createServer, type Server } from "node:http" import { streamText } from "ai" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" @@ -18,7 +19,7 @@ afterEach(async () => { }) const it = testEffect( - Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer), + LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, CrossSpawnSpawner.node])), ) it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 7fbf1f3be11..47051f6ed30 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,6 +1,8 @@ import { afterEach, expect, test } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect, Layer } from "effect" import { ModelsDev } from "@opencode-ai/core/models-dev" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -16,7 +18,8 @@ import { Provider } from "@/provider/provider" import { RuntimeFlags } from "@/effect/runtime-flags" import { Filesystem } from "@/util/filesystem" -import { InstanceLayer } from "@/project/instance-layer" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -57,14 +60,18 @@ afterEach(async () => { }) const providerLayer = (flags: Partial = {}) => - Provider.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelsDev.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), + LayerNode.compile( + LayerNode.group([ + Provider.node, + FSUtil.node, + Env.node, + Config.node, + Auth.node, + Plugin.node, + ModelsDev.node, + RuntimeFlags.node, + ]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], ) const list = Provider.use.list() @@ -77,7 +84,7 @@ const paid = (providers: Record (language as { config: { baseURL: string } }).config.baseURL -const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node]))) const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true })) const alphaProviderConfig = { @@ -356,6 +363,17 @@ it.instance( { config: { model: "anthropic/claude-sonnet-4-20250514" } }, ) +it.instance( + "defaultModel treats empty provider config as no allowlist", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const model = yield* Provider.use.defaultModel() + expect(model.providerID).toBeDefined() + expect(model.modelID).toBeDefined() + }), + { config: { provider: {} } }, +) + it.instance( "defaultModel returns a typed error when config excludes every provider", Effect.gen(function* () { @@ -652,6 +670,104 @@ it.instance("getSmallModel returns appropriate small model", () => }), ) +it.instance("getSmallModel prefers Gemini for Google Vertex", () => + Effect.gen(function* () { + yield* set("GOOGLE_VERTEX_PROJECT", "test-project") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.googleVertex) + expect(model).toBeDefined() + expect(model?.id).toContain("gemini") + }), +) + +it.instance( + "getSmallModel selects the latest model in the preferred family", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model?.id).toBe(ModelV2.ID.make("new-flash")) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "old-flash": { family: "gemini-flash", release_date: "2025-01-01" }, + "new-flash": { family: "gemini-flash", release_date: "2026-01-01" }, + "newer-haiku": { family: "claude-haiku", release_date: "2026-06-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "getSmallModel matches exact model families", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model?.id).toBe(ModelV2.ID.make("claude-haiku")) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "glm-flash": { family: "glm-flash", release_date: "2026-06-01" }, + "claude-haiku": { family: "claude-haiku", release_date: "2026-01-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + // kilocode_change start - Kilo always has an auto-routed small-model fallback + "getSmallModel falls back to Kilo auto when model IDs lack family metadata", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model).toMatchObject({ providerID: "kilo", id: "kilo-auto/small" }) + }), + // kilocode_change end + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "gpt-5-nano": { release_date: "2026-01-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance("getSmallModel skips inferred models for Azure", () => + Effect.gen(function* () { + yield* set("AZURE_RESOURCE_NAME", "test-resource") + yield* set("AZURE_API_KEY", "test-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.azure) + expect(model).toBeUndefined() + }), +) + +it.instance("getSmallModel skips inferred models for Azure Cognitive Services", () => + Effect.gen(function* () { + yield* set("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "test-resource") + yield* set("AZURE_COGNITIVE_SERVICES_API_KEY", "test-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("azure-cognitive-services")) + expect(model).toBeUndefined() + }), +) + it.instance( "getSmallModel respects config small_model override", Effect.gen(function* () { @@ -1016,6 +1132,8 @@ it.instance("ModelNotFoundError includes suggestions for typos", () => .pipe(Effect.flip) expect(error.suggestions).toBeDefined() expect((error.suggestions ?? []).length).toBeGreaterThan(0) + expect(error.message).toContain("Model not found: anthropic/claude-sonet-4") + expect(error.message).toContain("Did you mean:") }), ) @@ -1646,8 +1764,11 @@ it.instance( // Tests that need plugin file setup or multi-instance flows fall back to a // scoped tmpdir + provideInstance pattern via it.effect. +const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], +]) const provideMultiInstance = (eff: Effect.Effect) => - eff.pipe(Effect.provide(InstanceLayer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + eff.pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) it.effect("plugin config providers persist after instance dispose", () => Effect.gen(function* () { @@ -1751,7 +1872,7 @@ it.effect("opencode loader keeps paid models when config apiKey is present", () Provider.use .list() .pipe(provideInstanceEffect(directory)) - .pipe(Effect.provide(InstanceLayer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) const none = paid(yield* listIn(noneDir)) const keyedCount = paid(yield* listIn(keyedDir)) @@ -1770,7 +1891,7 @@ it.effect("opencode loader keeps paid models when auth exists", () => Provider.use .list() .pipe(provideInstanceEffect(directory)) - .pipe(Effect.provide(InstanceLayer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) const none = paid(yield* listIn(noneDir)) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 5c84e9a7777..dfa901724b1 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4,6 +4,7 @@ import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { jsonSchema } from "ai" describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -436,7 +437,12 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { } as any, system: [], messages: [{ role: "user", content: "Hello" }], - tools: {}, + tools: { + lookup: { + description: "Look up a value", + inputSchema: jsonSchema({ type: "object", properties: {} }), + }, + }, provider: { id: "azure", options: { useCompletionUrls: true } } as any, auth: undefined, plugin: { @@ -451,6 +457,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { expect(result.params.options.reasoningEffort).toBe("high") expect(result.params.options.reasoningSummary).toBeUndefined() expect(result.params.options.include).toBeUndefined() + expect(result.tools.lookup.strict).toBe(false) }) test("gpt-5.1 should have textVerbosity set to low", () => { @@ -658,6 +665,84 @@ describe("ProviderTransform.providerOptions", () => { }) }) + test("forces reasoning for custom OpenAI package models with explicit effort", () => { + const model = createModel({ + providerID: "meta", + api: { + id: "muse-spark", + url: "https://api.ai.meta.com/v1", + npm: "@ai-sdk/openai", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh", reasoningSummary: "auto" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh", reasoningSummary: "auto" }, + }) + }) + + test("forces reasoning for OpenAI package models marked reasoning-capable", () => { + expect(ProviderTransform.providerOptions(createModel(), { store: false })).toEqual({ + openai: { forceReasoning: true, store: false }, + }) + }) + + test("forces reasoning for explicit effort even when model is not marked reasoning-capable", () => { + const model = createModel({ + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Azure OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "azure", + api: { + id: "custom-gpt-5-deployment", + url: "https://azure.openai.example.com/openai/v1", + npm: "@ai-sdk/azure", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + azure: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Bedrock Mantle OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5-custom", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("overrides forceReasoning false when reasoning should be forced", () => { + expect( + ProviderTransform.providerOptions(createModel(), { forceReasoning: false, reasoningEffort: "xhigh" }), + ).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + test("uses gateway model provider slug for gateway models", () => { const model = createModel({ providerID: "vercel", @@ -752,7 +837,7 @@ describe("ProviderTransform.providerOptions", () => { }) expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium" })).toEqual({ - openai: { reasoningEffort: "medium" }, + openai: { forceReasoning: true, reasoningEffort: "medium" }, }) }) @@ -2360,6 +2445,82 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") }) + test("strips GitHub Copilot itemId from the copilot namespace, preserving other copilot options", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + copilot: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, + }, + }, + { + // The stale itemId on tool-call parts is what Copilot echoes back as the + // `function_call` item `id`, which is what the upstream connection rejects. + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: { + copilot: { itemId: "fc_456", reasoningEffort: "medium" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.copilot?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.copilot?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.copilot?.itemId).toBeUndefined() + expect(result[0].content[1].providerOptions?.copilot?.reasoningEffort).toBe("medium") + }) + + test("leaves a stray openai namespace on a Copilot model untouched, since Copilot's Responses model only reads the copilot namespace", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { itemId: "msg_456" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_456") + }) + test("preserves metadata for openai package when store is true", () => { const msgs = [ { @@ -3685,6 +3846,27 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic sonnet 5 returns adaptive thinking options with xhigh", () => { + const model = createMockModel({ + id: "anthropic/claude-sonnet-5", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-5", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + test("anthropic opus 4.6 omits display so it keeps the summarized default", () => { const model = createMockModel({ id: "anthropic/claude-opus-4-6", @@ -4334,6 +4516,12 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, }, + { + name: "sonnet 5", + apiIds: ["claude-sonnet-5", "claude-sonnet-5-20260630"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, { name: "fable 5", apiIds: ["claude-fable-5"], @@ -4431,6 +4619,28 @@ describe("ProviderTransform.variants", () => { effort: "high", }) }) + + test("sonnet 5 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-sonnet-5@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-sonnet-5@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) }) describe("@ai-sdk/amazon-bedrock", () => { @@ -4504,6 +4714,28 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic sonnet 5 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-sonnet-5", + providerID: "bedrock", + api: { + id: "anthropic.claude-sonnet-5", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => { const model = createMockModel({ id: "bedrock/llama-4", @@ -4686,6 +4918,12 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], thinking: { type: "adaptive", display: "summarized" }, }, + { + name: "sonnet 5", + apiIds: ["anthropic--claude-sonnet-5", "anthropic--claude-5-sonnet"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, ]) { for (const apiId of testCase.apiIds) { test(`${testCase.name} ${apiId} returns adaptive thinking variants under modelParams`, () => { diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index e9f312da70c..ba55f171009 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect" import { Question } from "../../src/question" import { InstanceRef } from "../../src/effect/instance-ref" @@ -10,16 +11,9 @@ import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { EventV2Bridge } from "../../src/event-v2-bridge" -const it = testEffect( - Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer), -) -const lifecycle = testEffect( - Layer.mergeAll( - Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), - CrossSpawnSpawner.defaultLayer, - testInstanceStoreLayer, - ), -) +const questionLayer = LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, CrossSpawnSpawner.node])) +const it = testEffect(questionLayer) +const lifecycle = testEffect(Layer.mergeAll(questionLayer, testInstanceStoreLayer)) const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { sessionID: SessionID diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 21faa82fe33..7a13553af61 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -1,4 +1,6 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Layer } from "effect" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" @@ -6,7 +8,9 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Project.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + LayerNode.compile(LayerNode.group([SessionNs.node, SessionProjector.node, Project.node, CrossSpawnSpawner.node])), +) const withSession = (input?: Parameters[0]) => Effect.acquireRelease(SessionNs.use.create(input), (created) => diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index 2fb6c4ac1b3..211b2e18847 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -56,9 +56,9 @@ const v2ApiLayer = HttpRouter.serve( { disableListenLog: true, disableLogger: true }, ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) -const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" }) -const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" }) -const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" }) +const noAuthLayer = ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" }) +const secretLayer = ServerAuth.Config.configLayer({ password: Option.some("secret"), username: "opencode" }) +const kitSecretLayer = ServerAuth.Config.configLayer({ password: Option.some("secret"), username: "kit" }) const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) diff --git a/packages/opencode/test/server/httpapi-compression.test.ts b/packages/opencode/test/server/httpapi-compression.test.ts index 967d8886e01..859fb06440e 100644 --- a/packages/opencode/test/server/httpapi-compression.test.ts +++ b/packages/opencode/test/server/httpapi-compression.test.ts @@ -95,9 +95,10 @@ describe("HttpApi compression", () => { }) test("when the response body is below the 1024-byte threshold", async () => { - // A bare config produces a tiny response (~few hundred bytes). + // kilocode_change - /config now serialises enough defaults to clear the threshold on its own, + // so use /global/health, which stays a handful of bytes. await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const response = await app().request("/config", { + const response = await app().request("/global/health", { headers: { "x-kilo-directory": tmp.path, "accept-encoding": "gzip" }, }) expect(response.status).toBe(200) diff --git a/packages/opencode/test/server/httpapi-control-plane.test.ts b/packages/opencode/test/server/httpapi-control-plane.test.ts index 2ddbe532cba..b837bf7556d 100644 --- a/packages/opencode/test/server/httpapi-control-plane.test.ts +++ b/packages/opencode/test/server/httpapi-control-plane.test.ts @@ -44,7 +44,7 @@ const apiLayer = HttpRouter.serve( moveSession: (value) => Ref.set(called, value), }), ), - Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })), + Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })), ) const it = testEffect(apiLayer) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 12f1dabb7d9..aa9fd26e43c 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { afterEach, describe, expect } from "bun:test" import { Effect, Layer, Queue, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" // kilocode_change - decode the legacy SSE wire format @@ -15,7 +16,7 @@ import { EventV2Bridge } from "../../src/event-v2-bridge" import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" import { SessionID } from "../../src/session/schema" import { Server } from "../../src/server/server" -import { SessionMessageID } from "@opencode-ai/core/session/message-id" +import { SessionMessage } from "@opencode-ai/core/session/message" // kilocode_change end import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -182,7 +183,7 @@ describe("event HttpApi", () => { ) // kilocode_change start - transformed EventV2 data is numeric on legacy SSE while domain data stays decoded - const v2 = testEffectShared(Layer.mergeAll(Bus.defaultLayer, EventV2Bridge.defaultLayer)) + const v2 = testEffectShared(Layer.mergeAll(AppNodeBuilder.build(Bus.node), AppNodeBuilder.build(EventV2Bridge.node))) v2.instance( "encodes catalog and session EventV2 data on the global event stream", @@ -235,7 +236,7 @@ describe("event HttpApi", () => { const sessionDomain = yield* events.publish(SessionEvent.Text.Delta, { sessionID, timestamp, - assistantMessageID: SessionMessageID.ID.create(), + assistantMessageID: SessionMessage.ID.create(), textID: "text-event-encoding", delta: "hello", }) @@ -250,9 +251,9 @@ describe("event HttpApi", () => { yield* events.publish(SessionEvent.Prompted, { sessionID, timestamp, - messageID: SessionMessageID.ID.create(), + messageID: SessionMessage.ID.create(), delivery: "queue", - prompt: new Prompt({ text: "hello", files: [], agents: [] }), // kilocode_change - upstream made prompt a Prompt class + prompt: Prompt.make({ text: "hello", files: [], agents: [] }), // kilocode_change - Prompt is a struct }) expect(properties(yield* Fiber.join(prompted))).toMatchObject({ timestamp: 1_234, diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index b33c36d7d9a..51c86925068 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -779,11 +779,11 @@ const scenarios: Scenario[] = [ .stream() .status( 200, - (ctx, result) => + (_ctx, result) => Effect.sync(() => { check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream") check(result.text.includes("server.connected"), "v2 event should emit initial connection event") - check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location") + check(!result.text.includes('"location"'), "v2 connection event should not be scoped to a location") }), "status", ), @@ -859,6 +859,20 @@ const scenarios: Scenario[] = [ object(body.location) array(body.data) }), + http.protected + .post("/api/session/{sessionID}/permission", "v2.session.permission.create") + .seeded((ctx) => ctx.session({ title: "Permission create owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/permission", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { action: "read", resources: [".env"] }, + })) + .json(200, (body) => { + object(body) + object(body.data) + check(typeof body.data.id === "string", "permission create should return an ID") + check(body.data.effect === "ask", "permission create should create a pending request") + }), http.protected .get("/api/session/{sessionID}/permission", "v2.session.permission.list") .seeded((ctx) => ctx.session({ title: "Permission list owner" })) @@ -867,6 +881,17 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, data(array)), + http.protected + .get("/api/session/{sessionID}/permission/{requestID}", "v2.session.permission.get") + .seeded((ctx) => ctx.session({ title: "Permission get owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/permission/{requestID}", { + sessionID: ctx.state.id, + requestID: "per_httpapi_missing", + }), + headers: ctx.headers(), + })) + .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/question", "v2.session.question.list") .seeded((ctx) => ctx.session({ title: "Question list owner" })) @@ -984,6 +1009,7 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(400, undefined, "none"), + http.protected.get("/api/session/active", "v2.session.active").json(200, data(object), "none"), http.protected .post("/api/session", "v2.session.create") .at((ctx) => ({ @@ -1000,6 +1026,24 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, data(object)), + http.protected + .post("/api/session/{sessionID}/agent", "v2.session.switchAgent") + .seeded((ctx) => ctx.session({ title: "Switch agent" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/agent", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { agent: "plan" }, + })) + .status(204, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/model", "v2.session.switchModel") + .seeded((ctx) => ctx.session({ title: "Switch model" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/model", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { model: { providerID: "opencode", id: "big-pickle" } }, + })) + .status(204, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ @@ -1007,6 +1051,28 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(404, object, "status"), + http.protected + .post("/api/session/{sessionID}/revert/stage", "v2.session.revert.stage") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/revert/stage", { sessionID: "ses_httpapi_missing" }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { messageID: "msg_httpapi_missing" }, + })) + .json(404, object, "status"), + http.protected + .post("/api/session/{sessionID}/revert/clear", "v2.session.revert.clear") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/revert/clear", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(404, object, "status"), + http.protected + .post("/api/session/{sessionID}/revert/commit", "v2.session.revert.commit") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/revert/commit", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/message", "v2.session.messages") .at((ctx) => ({ @@ -1046,6 +1112,65 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(400, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history") + .seeded((ctx) => ctx.session({ title: "Session history" })) + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?${new URLSearchParams({ + after: "0", + limit: "2", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.data) + check(typeof body.hasMore === "boolean", "Expected a history exhaustion signal") + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history.missing") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/history", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(404, object, "status"), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history.invalid") + .seeded((ctx) => ctx.session({ title: "Invalid history sequence" })) + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?after=-1`, + headers: ctx.headers(), + })) + .json(400, object, "status"), + http.protected + .get("/api/session/{sessionID}/event", "v2.session.events.missing") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/event", { sessionID: "ses_httpapi_missing" })}?after=0`, + headers: ctx.headers(), + })) + .status(404, undefined, "status"), + http.protected + .post("/api/session/{sessionID}/interrupt", "v2.session.interrupt") + .seeded((ctx) => ctx.session({ title: "Interrupt session" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/interrupt", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .status(204, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/message/{messageID}", "v2.session.message.missing") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/message/{messageID}", { + sessionID: "ses_httpapi_missing", + messageID: "msg_httpapi_missing", + }), + headers: ctx.headers(), + })) + .json(404, object, "status"), http.protected .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .seeded((ctx) => ctx.session({ title: "Invalid prompt owner" })) @@ -1175,7 +1300,7 @@ const scenarios: Scenario[] = [ .seeded((ctx) => Effect.gen(function* () { const session = yield* ctx.session({ title: "Todo session" }) - const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + const todos = [{ content: "cover session todo", status: "pending" as const, priority: "high" as const }] yield* ctx.todos(session.id, todos) return { session, todos } }), diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index ce141603f7f..4f26227cf87 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -126,5 +126,9 @@ export type Result = | { status: "skip"; scenario: TodoScenario } export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } -export type TodoInfo = { content: string; status: string; priority: string } +export type TodoInfo = { + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + priority: "high" | "medium" | "low" +} export type MessageSeed = { info: SessionV1.User; part: SessionV1.TextPart } diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 1a183f4f6a3..7c3de6582ae 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect } from "bun:test" import { realpath } from "node:fs/promises" // kilocode_change +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" @@ -16,7 +17,7 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance function request(path: string, directory: string, init: RequestInit = {}) { diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 6abb5e58be3..f31d8c59c23 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Context } from "effect" +import { Context, Effect } from "effect" import path from "path" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { pollWithTimeout } from "../lib/effect" const context = Context.empty() as Context.Context @@ -58,17 +59,26 @@ describe("file HttpApi", () => { await using tmp = await tmpdir({ git: true }) await Bun.write(path.join(tmp.path, "hello.txt"), "needle") - const [text, files, symbols] = await Promise.all([ + const [text, symbols] = await Promise.all([ request(FilePaths.findText, tmp.path, { pattern: "needle" }), - request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }), request(FilePaths.findSymbol, tmp.path, { query: "hello" }), ]) + const files = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }) + const body = await response.json() + return body.includes("hello.txt") ? { response, body } : undefined + }), + "file search index was not ready", + ), + ) expect(text.status).toBe(200) expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) - expect(files.status).toBe(200) - expect(await files.json()).toContain("hello.txt") + expect(files.response.status).toBe(200) + expect(files.body).toContain("hello.txt") expect(symbols.status).toBe(200) expect(await symbols.json()).toEqual([]) diff --git a/packages/opencode/test/server/httpapi-global.test.ts b/packages/opencode/test/server/httpapi-global.test.ts index 91c5b483145..bcbe7aecbba 100644 --- a/packages/opencode/test/server/httpapi-global.test.ts +++ b/packages/opencode/test/server/httpapi-global.test.ts @@ -38,7 +38,7 @@ const apiLayer = HttpRouter.serve( upgrade: () => Effect.void, }), ), - Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })), + Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })), ) const it = testEffect(apiLayer) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 0b0752eac98..263e2c472b9 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -8,11 +8,9 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" @@ -46,16 +44,7 @@ const testStateLayer = Layer.effectDiscard( const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }) -const it = testEffect( - Layer.mergeAll( - testStateLayer, - NodeHttpServer.layerTest, - NodeServices.layer, - InstanceLayer.layer, - Project.defaultLayer, - workspaceLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), -) +const it = testEffect(Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, workspaceLayer)) const instanceContextTestLayer = Layer.mergeAll( instanceContextLayer, diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts index eb864077160..044a77fd222 100644 --- a/packages/opencode/test/server/httpapi-promptasync-context.test.ts +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -16,11 +16,9 @@ import * as Socket from "effect/unstable/socket/Socket" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { mkdir } from "node:fs/promises" import { registerAdapter } from "../../src/control-plane/adapters" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { @@ -51,16 +49,7 @@ const testStateLayer = Layer.effectDiscard( const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }) -const it = testEffect( - Layer.mergeAll( - testStateLayer, - NodeHttpServer.layerTest, - NodeServices.layer, - InstanceLayer.layer, - Project.defaultLayer, - workspaceLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), -) +const it = testEffect(Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, workspaceLayer)) const instanceContextTestLayer = Layer.mergeAll( instanceContextLayer, diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index bdc4dfaacc8..b7dba1bf86f 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect, Layer } from "effect" import path from "path" @@ -15,7 +16,7 @@ const testStateLayer = Layer.effectDiscard( ), ) -const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(testStateLayer, LayerNode.compile(FSUtil.node), httpApiLayer)) const projectOptions = { config: { formatter: false, lsp: false } } const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index d13d19c506e..310ebae5957 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -10,6 +10,8 @@ type OpenApiSchema = { readonly enum?: readonly unknown[] readonly properties?: Record readonly required?: readonly string[] + readonly contentSchema?: OpenApiSchema + readonly contentMediaType?: string } type OpenApiResponse = { readonly description?: string @@ -68,6 +70,20 @@ function isBuiltInEndpointError(name: string) { } describe("PublicApi OpenAPI v2 errors", () => { + test("includes plugin-facing core schemas", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(Object.keys(spec.components.schemas)).toEqual( + expect.arrayContaining([ + "CredentialValue", + "IntegrationInputs", + "IntegrationMethod", + "IntegrationRef", + "SkillV2Source", + ]), + ) + }) + test("documents nested legacy global sync events", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec const schema = spec.components.schemas.SyncEventSessionCreated @@ -85,6 +101,21 @@ describe("PublicApi OpenAPI v2 errors", () => { }) }) + test("names the v2 event union without the SSE string wrapper collision", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(spec.components.schemas.V2Event1).toBeUndefined() + expect(spec.components.schemas.V2Event?.anyOf?.length).toBeGreaterThan(0) + expect(spec.components.schemas.V2EventStream).toMatchObject({ + type: "string", + contentMediaType: "application/json", + contentSchema: { $ref: "#/components/schemas/V2Event" }, + }) + expect(spec.paths["/api/event"]?.get?.responses?.["200"]?.content?.["text/event-stream"]?.schema).toEqual({ + $ref: "#/components/schemas/V2Event", + }) + }) + test("preserves /api auth responses", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index a95d36e5d4a..8cbfe10b06e 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -24,7 +24,7 @@ import { SessionPaths, } from "../../src/server/routes/instance/httpapi/groups/session" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" -import { SessionMessagesQuery } from "@opencode-ai/server/groups/message" +import { SessionMessagesQuery } from "@opencode-ai/protocol/groups/message" import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/server/httpapi-reference.test.ts b/packages/opencode/test/server/httpapi-reference.test.ts index 1de0e8707ef..97248e3453a 100644 --- a/packages/opencode/test/server/httpapi-reference.test.ts +++ b/packages/opencode/test/server/httpapi-reference.test.ts @@ -4,6 +4,8 @@ import { Server } from "../../src/server/server" import { Global } from "@opencode-ai/core/global" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { Effect } from "effect" +import { pollWithTimeout } from "../lib/effect" afterEach(async () => { await disposeAllInstances() @@ -24,37 +26,36 @@ describe("reference HttpApi", () => { }, }) - const response = await Server.Default().app.request("/api/reference", { - headers: { "x-kilo-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - const body = await response.json() + const body = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await Server.Default().app.request("/api/reference", { + headers: { "x-kilo-directory": tmp.path }, + }) + expect(response.status).toBe(200) + const body = await response.json() + return body.data.length === 0 ? undefined : body + }), + "references were not loaded", + ), + ) expect(body).toMatchObject({ location: { directory: tmp.path } }) expect(body.data).toEqual([ { name: "docs", path: path.join(tmp.path, "docs"), - description: null, - hidden: null, source: { type: "local", path: path.join(tmp.path, "docs"), - description: null, - hidden: null, }, }, { name: "effect", path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"), - description: null, - hidden: null, source: { type: "git", repository: "Effect-TS/effect", branch: "main", - description: null, - hidden: null, }, }, ]) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index c650b3772ad..4ea6fd38e84 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" @@ -16,7 +17,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) const text = (response: HttpClientResponse.HttpClientResponse) => response.text diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 13241458c88..2521ae471a4 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -5,12 +5,14 @@ import { Deferred, Effect, Layer } from "effect" import type * as Scope from "effect/Scope" import { HttpServer } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process" +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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" import { createKiloClient } from "@kilocode/sdk/v2" import { validateSession } from "../../src/cli/tui/validate-session" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" @@ -29,16 +31,12 @@ import { ModelV2 } from "@opencode-ai/core/model" import { Database } from "@opencode-ai/core/database/database" import { httpApiLayer } from "./httpapi-layer" -const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const it = testEffect( - Layer.mergeAll( - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), - Database.defaultLayer, - httpApiLayer, - ), +const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const appLayer = AppNodeBuilder.build( + LayerNode.group([FSUtil.node, CrossSpawnSpawner.node, InstanceStore.node, Database.node, SessionNs.node]), + [[InstanceStore.bootstrapNode, noopBootstrapLayer]], ) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) const original = { KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD, @@ -55,6 +53,7 @@ type TestServices = | FSUtil.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service + | SessionNs.Service | HttpServer.HttpServer type TestScope = Scope.Scope | TestServices @@ -332,7 +331,7 @@ function seedMessage(directory: string, sessionID: string) { }) return { message, part } }), - ).pipe(Effect.provide(SessionNs.defaultLayer)), + ), ), ) } @@ -410,7 +409,12 @@ describe("HttpApi SDK", () => { workspaceID, onRequest: (value) => (request = value), }) - const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" })) + const found = yield* pollWithTimeout( + call(() => sdk.v2.fs.find({ query: "hello", type: "file" })).pipe( + Effect.map((result) => (result.data?.data.length ? result : undefined)), + ), + "SDK file search index was not ready", + ) const url = new URL(request!.url) expect(found.response.status).toBe(200) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 7306b983e1e..ff24f1d6e2b 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -7,6 +7,8 @@ import path from "node:path" import { Cause, Config, Effect, Exit, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" import { Ripgrep } from "@opencode-ai/core/ripgrep" @@ -14,7 +16,6 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" -import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" @@ -23,7 +24,6 @@ import * as HttpSessionError from "../../src/server/routes/instance/httpapi/hand import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" -import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@opencode-ai/core/database/database" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -38,14 +38,13 @@ import { testProviderConfig } from "../lib/test-provider" import { pollWithTimeout, testEffect } from "../lib/effect" // kilocode_change const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES -const workspaceLayer = Workspace.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), +const noopBootstrapLayer = Layer.succeed( + InstanceBootstrapService.Service, + InstanceBootstrapService.Service.of({ run: Effect.void }), ) -const instanceStoreLayer = InstanceStore.defaultLayer.pipe( - Layer.provide( - Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })), - ), +const appLayer = AppNodeBuilder.build( + LayerNode.group([InstanceStore.node, Project.node, Session.node, Workspace.node, Database.node, Ripgrep.node]), + [[InstanceStore.bootstrapNode, noopBootstrapLayer]], ) const servedRoutes: Layer.Layer = HttpRouter.serve( HttpApiApp.routes, @@ -59,16 +58,7 @@ const httpApiLayer = servedRoutes.pipe( Layer.provideMerge(NodeHttpServer.layerTest), Layer.provideMerge(NodeServices.layer), ) -const it = testEffect( - Layer.mergeAll( - instanceStoreLayer, - Project.defaultLayer, - Session.defaultLayer, - workspaceLayer, - Database.defaultLayer, - httpApiLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), -) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) function pathFor(path: string, params: Record) { return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) @@ -129,7 +119,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) => Effect.gen(function* () { - const message = new SessionMessage.Assistant({ + const message = SessionMessage.Assistant.make({ id: SessionMessage.ID.create(), type: "assistant", agent: "build", @@ -472,7 +462,7 @@ describe("session HttpApi", () => { cwd: sessionDirectory, root: sessionDirectory, }) - }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), ) it.instance( @@ -651,7 +641,7 @@ describe("session HttpApi", () => { request(`/api/session/${session.id}/prompt`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }), + body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" }, resume: false }), }) const first = yield* recordPrompt() const retried = yield* recordPrompt() @@ -694,6 +684,22 @@ describe("session HttpApi", () => { message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt", resource: "msg_http_prompt", }) + + const wakeID = SessionMessage.ID.make("msg_http_wake") + const wake = yield* request(`/api/session/${session.id}/prompt`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ id: wakeID, prompt: { text: "hello again" } }), + }) + expect(wake.status).toBe(200) + const message = yield* pollWithTimeout( + requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe( + Effect.map(({ data }) => data.find((message) => message.id === wakeID)), + ), + "V2 prompt was not promoted after wake", + "10 seconds", + ) + expect(message).toMatchObject({ id: wakeID, type: "user" }) }), { git: true, config: { formatter: false, lsp: false } }, ) @@ -922,7 +928,7 @@ describe("session HttpApi", () => { pathSession: yield* createSession(), pathlessSession: yield* createSession(), } - }).pipe(Effect.provideService(TestInstance, { directory: currentDir }), Effect.provide(Session.defaultLayer)), + }).pipe(Effect.provideService(TestInstance, { directory: currentDir })), ) yield* clearSessionPath(pathlessSession.id) @@ -1059,7 +1065,7 @@ describe("session HttpApi", () => { }).pipe( Effect.ensuring(Effect.sync(() => release.resolve())), Effect.provide(TestLLMServer.layer), - Effect.provide(CrossSpawnSpawner.defaultLayer), + Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), ) }, 30_000, // kilocode_change - windows CI needs headroom beyond 10s diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 62654942cd3..1f9edc194e5 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, mock } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Context, Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" @@ -11,7 +12,7 @@ import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES const context = Context.empty() as Context.Context -const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(Session.node), httpApiLayer)) afterEach(async () => { mock.restore() diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 08d84acd2e7..2a72649ea4b 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto" import { describe, expect } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { ConfigProvider, Effect, Layer } from "effect" +import { ConfigProvider, Effect, Layer, Option } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { HttpClient, HttpClientRequest, @@ -38,7 +39,15 @@ const testStateLayer = Layer.effectDiscard( }), ) -const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, RuntimeFlags.layer())) +const fsUtilLayer = AppNodeBuilder.build(FSUtil.node) +const it = testEffect(Layer.mergeAll(testStateLayer, fsUtilLayer, RuntimeFlags.layer())) + +function authConfigLayer(input?: { password?: string; username?: string }) { + return ServerAuth.Config.configLayer({ + password: input?.password === undefined ? Option.none() : Option.some(input.password), + username: input?.username ?? "opencode", + }) +} function restoreEnv(key: string, value: string | undefined) { if (value === undefined) { @@ -97,17 +106,15 @@ function uiApp(input?: { ) }), ).pipe( - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(authConfigLayer(input)))), Layer.provide([ - FSUtil.defaultLayer, + fsUtilLayer, input?.client ?? httpClient(new Response("ui")), RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }), HttpServer.layerServices, // kilocode_change start - keep the filewatcher-disable flag visible (see httpapi-instance-route-auth.test.ts) ConfigProvider.layer( ConfigProvider.fromUnknown({ - KILO_SERVER_PASSWORD: input?.password, - KILO_SERVER_USERNAME: input?.username, KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true", }), ), @@ -148,7 +155,7 @@ function routeOrderingApp() { }), ).pipe( Layer.provide([ - FSUtil.defaultLayer, + fsUtilLayer, RuntimeFlags.layer({ disableEmbeddedWebUi: true }), httpClient(new Response("ui"), (request) => { proxiedUrl = request.url diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index edd53082d97..7fec7375777 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" import { Context, Schema } from "effect" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../fixture/db" @@ -19,22 +21,50 @@ function request(route: string, directory: string, init: RequestInit = {}) { } const Event = Schema.Struct({ - id: Schema.String, + id: EventV2.ID, type: Schema.String, - location: Schema.Struct({ - directory: Schema.String, - project: Schema.Struct({ id: Schema.String, directory: Schema.String }), - }), + location: Schema.optional(Location.Ref), data: Schema.Unknown, }) -async function readEvent(reader: ReadableStreamDefaultReader) { - const value = await reader.read() - if (value.done) throw new Error("event stream closed") - return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, ""))) +async function* eventStream(body: ReadableStream) { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + const boundary = buffer.match(/(?:\r\n|\r|\n){2}/) + if (!boundary || boundary.index === undefined) { + const value = await reader.read() + if (value.done) return + buffer += decoder.decode(value.value, { stream: true }) + continue + } + + const record = buffer.slice(0, boundary.index) + buffer = buffer.slice(boundary.index + boundary[0].length) + const data = record + .split(/\r\n|\r|\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + if (data.length) yield Schema.decodeUnknownSync(Event)(JSON.parse(data.join("\n"))) + } + } finally { + try { + await reader.cancel() + } finally { + reader.releaseLock() + } + } } -async function readEventType(reader: ReadableStreamDefaultReader, type: string) { +async function readEvent(reader: AsyncIterator) { + const value = await reader.next() + if (value.done) throw new Error("event stream closed") + return value.value +} + +async function readEventType(reader: AsyncIterator, type: string) { for (let index = 0; index < 20; index++) { const event = await readEvent(reader) if (event.type === type) return event @@ -60,35 +90,61 @@ describe("v2 location HttpApi", () => { }) // kilocode_change end - test("returns command and skill snapshots with resolved locations", async () => { - await using tmp = await tmpdir({ git: true }) - - for (const route of ["/api/command", "/api/skill"]) { - const response = await request(route, tmp.path) - expect(response.status).toBe(200) - const body = (await response.json()) as { - location: { directory: string; project: { id: string } } - data: unknown - } - expect(body.data).toBeArray() - expect(body.location.directory).toBe(tmp.path) - expect(body.location.project.id).toBeTruthy() - } + test("decodes EventV2 location refs without resolved project metadata", () => { + expect( + Schema.decodeUnknownSync(Event)({ + id: "evt_test", + type: "file.watcher.updated", + location: { directory: "/tmp/project" }, + data: {}, + }), + ).toMatchObject({ location: { directory: "/tmp/project" } }) }) - test("streams native EventV2 payloads with resolved locations", async () => { - await using tmp = await tmpdir({ git: true }) - const response = await request("/api/event", tmp.path) - const reader = response.body!.getReader() - expect((await readEvent(reader)).type).toBe("server.connected") + test("returns command and skill snapshots with resolved locations", async () => { + await using tmp = await tmpdir({ git: true }) - const created = await request("/session", tmp.path, { method: "POST" }) + for (const route of ["/api/command", "/api/skill"]) { + const response = await request(route, tmp.path) + expect(response.status).toBe(200) + const body = (await response.json()) as { + location: { directory: string; project: { id: string } } + data: unknown + } + expect(body.data).toBeArray() + expect(body.location.directory).toBe(tmp.path) + expect(body.location.project.id).toBeTruthy() + } +}) + + test("streams native EventV2 payloads across locations", async () => { + await using subscriber = await tmpdir({ git: true }) + await using publisher = await tmpdir({ git: true }) + const response = await request("/api/event", subscriber.path) + const reader = eventStream(response.body!) + const connected = await readEvent(reader) + expect(connected.type).toBe("server.connected") + expect(connected.location).toBeUndefined() + + const created = await request("/session", publisher.path, { method: "POST" }) expect(created.status).toBe(200) + + // kilocode_change start - the native handler must encode Kilo events omitted from upstream's narrower manifest + const session = (await created.json()) as { id: string } expect(await readEventType(reader, "session.created")).toMatchObject({ type: "session.created", - location: { directory: tmp.path, project: { directory: tmp.path } }, - data: { sessionID: expect.any(String) }, + location: { directory: publisher.path }, + data: { sessionID: session.id }, }) - await reader.cancel() + + const aborted = await request(`/session/${session.id}/abort`, publisher.path, { method: "POST" }) + expect(aborted.status).toBe(200) + expect(await readEventType(reader, "session.status")).toMatchObject({ + type: "session.status", + location: { directory: publisher.path }, + data: { sessionID: session.id, status: { type: "idle" } }, + }) + // kilocode_change end + await reader.return(undefined) }) }) diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 8720c3eca62..6e3911f9a66 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -21,7 +21,6 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { Database } from "@opencode-ai/core/database/database" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" @@ -55,11 +54,9 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - Database.defaultLayer, - Project.defaultLayer, workspaceLayer, Socket.layerWebSocketConstructorGlobal, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), + ), ) type ProxiedRequest = { diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index a34c028687a..872dee865c6 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, mock } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect, Layer, Stream } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -23,20 +25,11 @@ import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES -const workspaceLayer = Workspace.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), -) -const it = testEffect( - Layer.mergeAll( - Project.defaultLayer, - Session.defaultLayer, - workspaceLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)), - Database.defaultLayer, - httpApiLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), +const appLayer = AppNodeBuilder.build( + LayerNode.group([Project.node, Session.node, Workspace.node, InstanceStore.node, Database.node, Ripgrep.node]), + [[InstanceStore.bootstrapNode, InstanceBootstrap.node]], ) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) function request(path: string, directory: string, init: RequestInit = {}) { return requestInDirectory(path, directory, init) diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts index b23726965fd..453aa189d5a 100644 --- a/packages/opencode/test/server/negative-tokens-regression.test.ts +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -6,6 +6,7 @@ // strict `NonNegativeInt` schema then made every load of the message list // fail to encode, killing Desktop boot for every user with such a row. import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { eq } from "drizzle-orm" @@ -21,7 +22,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) function seedNegativeTokenSession() { return Effect.gen(function* () { diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts index 5b2deb87a99..0f783622a6c 100644 --- a/packages/opencode/test/server/project-copy.test.ts +++ b/packages/opencode/test/server/project-copy.test.ts @@ -2,12 +2,14 @@ import { afterEach, describe, expect } from "bun:test" import { $ } from "bun" 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 { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { FSUtil } from "@opencode-ai/core/fs-util" import { Database } from "@opencode-ai/core/database/database" import { Snapshot } from "@/snapshot" -import { InstanceBootstrap } from "@/project/bootstrap-service" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -20,9 +22,13 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) +const testInstanceStore = AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) const it = testEffect( - Layer.mergeAll(FSUtil.defaultLayer, Database.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer), + Layer.mergeAll( + AppNodeBuilder.build(LayerNode.group([FSUtil.node, Database.node, Snapshot.node])), + testInstanceStore, + httpApiLayer, + ), ) function request(directory: string, url: string, init: RequestInit = {}) { diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 0e81e8f1a8e..80af2c53d3a 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect } from "bun:test" +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 { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import path from "path" import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" @@ -19,9 +21,11 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) +const testInstanceStore = AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) -const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer)) +const it = testEffect( + Layer.mergeAll(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Snapshot.node])), testInstanceStore, httpApiLayer), +) function request(directory: string, url: string, init: RequestInit = {}) { return requestInDirectory(url, directory, init) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index a4ad3793bc7..cf27c74cbec 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, mock } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { Session as SessionNs } from "@/session/session" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) afterEach(async () => { mock.restore() diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index a031d365263..f6b25aa6281 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -11,6 +11,7 @@ * asserts that GET /session//diff returns 200 with the row intact. // kilocode_change */ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" @@ -24,7 +25,7 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Storage.node])), httpApiLayer)) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 213e3cdce3c..354a578b23c 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Session as SessionNs } from "@/session/session" @@ -9,24 +11,12 @@ import path from "path" import { SessionTable } from "@opencode-ai/core/session/sql" import { eq } from "drizzle-orm" import { testEffect } from "../lib/effect" -import { EventV2Bridge } from "@/event-v2-bridge" -import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" -import { BackgroundJob } from "@/background/job" const layer = (experimentalWorkspaces: boolean) => - Layer.mergeAll( - Database.defaultLayer, - SessionNs.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(SessionProjector.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), - Layer.provide(BackgroundJob.defaultLayer), - ), - ) + AppNodeBuilder.build(LayerNode.group([Database.node, SessionNs.node, SessionProjector.node]), [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })], + ]) const it = testEffect(layer(false)) const itWorkspaces = testEffect(layer(true)) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 58d6ff04f95..d9d0528ee4d 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { Session as SessionNs } from "@/session/session" @@ -12,7 +13,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) const model = { providerID: ProviderV2.ID.make("test"), diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index a9bc84202be..82a8ad93955 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -1,11 +1,12 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { Session } from "@/session/session" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(Session.node), httpApiLayer)) describe("tui.selectSession endpoint", () => { it.instance( diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 91a0d0b6507..fbb683ac9ed 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -7,13 +7,11 @@ import { APICallError } from "ai" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect" import * as Stream from "effect/Stream" import { Config } from "@/config/config" -import { Image } from "@/image/image" -import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" -import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" +import { Snapshot } from "@/snapshot" // kilocode_change import { provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" @@ -22,10 +20,10 @@ import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" -import type { Provider } from "@/provider/provider" +import { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" -import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -34,6 +32,8 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { LLMEvent, Usage } from "@opencode-ai/llm" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const summary = Layer.succeed( SessionSummary.Service, @@ -210,7 +210,7 @@ function fake( } satisfies SessionProcessorModule.SessionProcessor.Handle } -function layer(result: "continue" | "compact") { +function processorLayer(result: "continue" | "compact") { return Layer.succeed( SessionProcessorModule.SessionProcessor.Service, SessionProcessorModule.SessionProcessor.Service.of({ @@ -221,38 +221,28 @@ function layer(result: "continue" | "compact") { function cfg(compaction?: ConfigV1.Info["compaction"]) { const base = Schema.decodeUnknownSync(ConfigV1.Info)({}) as ConfigV1.Info - return TestConfig.layer({ - get: () => Effect.succeed({ ...base, compaction }), - }) + return Layer.succeed(Config.Service, TestConfig.make({ get: () => Effect.succeed({ ...base, compaction }) })) } -const deps = Layer.mergeAll( - wide().layer, - layer("continue"), - Agent.defaultLayer, - Plugin.defaultLayer, - EventV2Bridge.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer({ experimentalEventSystem: true }), - Database.defaultLayer, - EventV2Bridge.defaultLayer, -) - -const env = Layer.mergeAll( - SessionNs.defaultLayer, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - CrossSpawnSpawner.defaultLayer, - SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)), -) +const defaultProvider = wide() +const compactionTestNode = LayerNode.group([ + SessionCompaction.node, + SessionNs.node, + SessionProjector.node, + Database.node, + EventV2Bridge.node, + CrossSpawnSpawner.node, +]) +const env = AppNodeBuilder.build(compactionTestNode, [ + [Provider.node, defaultProvider.layer], + [SessionProcessorModule.SessionProcessor.node, processorLayer("continue")], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], +]) const it = testEffect(env) -const compactionEnv = Layer.mergeAll( - SessionNs.defaultLayer, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - CrossSpawnSpawner.defaultLayer, +const compactionEnv = AppNodeBuilder.build( + LayerNode.group([SessionNs.node, SessionProjector.node, Database.node, EventV2Bridge.node, CrossSpawnSpawner.node]), ) const itCompaction = testEffect(compactionEnv) @@ -260,7 +250,7 @@ type CompactionProcessOptions = { result?: "continue" | "compact" llm?: Layer.Layer plugin?: Layer.Layer - provider?: ReturnType + provider?: ReturnType config?: Layer.Layer flags?: Partial // kilocode_change snapshot?: Layer.Layer // kilocode_change @@ -271,30 +261,26 @@ function withCompaction(options?: CompactionProcessOptions) { } function compactionProcessLayer(options?: CompactionProcessOptions) { - const events = EventV2Bridge.defaultLayer - const status = SessionStatus.layer.pipe(Layer.provide(events)) - const processor = options?.llm - ? SessionProcessorModule.SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provide(status), - ) - : layer(options?.result ?? "continue") - return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, events, status).pipe( - Layer.provide(SessionNs.defaultLayer), - Layer.provide((options?.provider ?? wide()).layer), - Layer.provide(options?.snapshot ?? Snapshot.defaultLayer), // kilocode_change - Layer.provide(options?.llm ?? LLM.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(options?.plugin ?? Plugin.defaultLayer), - Layer.provide(status), - Layer.provide(events), - Layer.provide(options?.config ?? Config.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true, ...options?.flags })), // kilocode_change - Layer.provide(EventV2Bridge.defaultLayer), - ) + const replacements: LayerNode.Replacements = [ + [Provider.node, (options?.provider ?? wide()).layer], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true, ...options?.flags })], // kilocode_change + [SessionSummary.node, summary], + ...(options?.snapshot ? ([[Snapshot.node, options.snapshot]] as const) : []), // kilocode_change + ] + if (!options?.llm) { + return AppNodeBuilder.build(compactionTestNode, [ + ...replacements, + [SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")], + ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), + ...(options?.config ? ([[Config.node, options.config]] as const) : []), + ]) + } + return AppNodeBuilder.build(compactionTestNode, [ + ...replacements, + [LLM.node, options.llm], + ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), + ...(options?.config ? ([[Config.node, options.config]] as const) : []), + ]) } // kilocode_change start - keep retry-backoff cancellation tests independent of git snapshot cleanup latency @@ -336,7 +322,7 @@ function llm() { push(stream: Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream)) { queue.push(stream) }, - layer: Layer.succeed( + llmLayer: Layer.succeed( LLM.Service, LLM.Service.of({ stream: (input) => { @@ -632,8 +618,7 @@ describe("session.compaction.create", () => { }) const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( - Effect.provide(SessionExecution.noopLayer), - Effect.provide(SessionV2.defaultLayer), + Effect.provide(AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])), ) expect(v2.at(-1)).toMatchObject({ type: "compaction", @@ -873,12 +858,12 @@ describe("session.compaction.process", () => { const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const done = yield* Deferred.make() - let seen = false + const seen: string[] = [] const unsub = yield* events.listen((evt) => { + seen.push(evt.type) if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id) return Effect.void - seen = true Deferred.doneUnsafe(done, Effect.void) return Effect.void }) @@ -893,7 +878,8 @@ describe("session.compaction.process", () => { yield* Deferred.await(done).pipe(Effect.timeout("500 millis")) expect(result).toBe("continue") - expect(seen).toBe(true) + expect(seen).toContain(SessionCompaction.Event.Compacted.type) + expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) }), ) @@ -1064,7 +1050,7 @@ describe("session.compaction.process", () => { expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("yyyy") - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) }, { git: true }, ) @@ -1101,7 +1087,7 @@ describe("session.compaction.process", () => { expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("recent image turn") expect(captured).toContain("Attached image/png: big.png") - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) @@ -1152,7 +1138,7 @@ describe("session.compaction.process", () => { expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) @@ -1303,25 +1289,17 @@ describe("session.compaction.process", () => { }) .pipe(Effect.forkChild) - // kilocode_change start - Effect 4.x timeout throws TimeoutError on the error - // channel; on loaded Windows CI runners the fiber may not reach the retry state - // within 1 second. Swallow the TimeoutError so the test proceeds to interrupt the - // fiber regardless — the assertions verify the interrupt exit either way. - yield* Deferred.await(ready).pipe( - Effect.timeout("1 second"), - Effect.catchTag("TimeoutError", () => Effect.void), - ) + yield* Deferred.await(ready).pipe(Effect.timeout("5 seconds")) yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber) - // kilocode_change end expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(Cause.hasInterrupts(exit.cause)).toBe(true) } - }).pipe(withCompaction({ llm: stub.layer, snapshot: snap })) // kilocode_change + }).pipe(withCompaction({ llm: stub.llmLayer, snapshot: snap })) // kilocode_change }, - {}, // kilocode_change - isolate cancellation from git setup + { timeout: 10_000 }, // kilocode_change - snapshot is isolated above ) itCompaction.instance( @@ -1399,7 +1377,7 @@ describe("session.compaction.process", () => { expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false) // Sanity: the text part still got through. expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true) - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1435,7 +1413,7 @@ describe("session.compaction.process", () => { expect(summary?.info.role).toBe("assistant") expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1472,7 +1450,7 @@ describe("session.compaction.process", () => { expect(captured).not.toContain("keep this turn") expect(captured).not.toContain("and this one too") expect(captured).not.toContain("What did we do so far?") - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1514,7 +1492,7 @@ describe("session.compaction.process", () => { expect(captured.match(/summary one/g)?.length).toBe(1) expect(captured).toContain("## Constraints & Preferences") expect(captured).toContain("## Progress") - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1556,7 +1534,7 @@ describe("session.compaction.process", () => { expect( filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), ).toBe(true) - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }) itCompaction.instance( diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 53ccf06e120..cdbc6e66d70 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -2,34 +2,42 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import path from "path" import { Effect, FileSystem, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" -import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { FSUtil } from "@opencode-ai/core/fs-util" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { RuntimeFlags } from "../../src/effect/runtime-flags" -import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +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 { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Config } from "@/config/config" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem, InstanceStore.node]), [ + [ + InstanceBootstrap.node, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], + ]), +) -const configLayer = TestConfig.layer() +const configLayer = Layer.succeed(Config.Service, TestConfig.make()) const instructionLayer = (global: Partial, flags: Partial = {}) => - Instruction.layer.pipe( - Layer.provide(configLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Global.layerWith(global)), - Layer.provide(RuntimeFlags.layer(flags)), - ) + AppNodeBuilder.build(Instruction.node, [ + [Config.node, configLayer], + [Global.node, Global.layerWith(global)], + [RuntimeFlags.node, RuntimeFlags.layer(flags)], + ]) const provideInstruction = (global: Partial, flags?: Partial) => diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 1757bde6b70..70368b5be3e 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -1,6 +1,5 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { FSUtil } from "@opencode-ai/core/fs-util" import { ModelsDev } from "@opencode-ai/core/models-dev" import { HttpRecorder } from "@opencode-ai/http-recorder" import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" @@ -10,14 +9,11 @@ import { Effect, Layer, Option, Schema, Stream } from "effect" import path from "node:path" import z from "zod" import { Auth } from "@/auth" -import { Config } from "@/config/config" -import { Plugin } from "@/plugin" import { Provider } from "@/provider/provider" import { Filesystem } from "@/util/filesystem" import { LLMEvent, LLMResponse } from "@opencode-ai/llm" -import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" -import { Env } from "@/env" +import { RequestExecutor } from "@opencode-ai/llm/route" import { RuntimeFlags } from "@/effect/runtime-flags" import type { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" @@ -26,6 +22,9 @@ import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +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" const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings") const KILO_FIXTURES_DIR = path.join(FIXTURES_DIR, "kilocode") // kilocode_change @@ -243,7 +242,7 @@ const redactRecordedBody = (body: string) => function authLayer(scenario: RecordedScenario) { const replayAuth = shouldRecord ? scenario.recordAuth?.() : scenario.replayAuth - if (!replayAuth) return Auth.defaultLayer + if (!replayAuth) return undefined return Layer.mock(Auth.Service)({ get: (providerID) => Effect.succeed(providerID === scenario.providerID ? replayAuth : undefined), all: () => Effect.succeed({ [scenario.providerID]: replayAuth }), @@ -265,15 +264,6 @@ const modelsFixture = Filesystem.readJson>( function recordedNativeLLMLayer(scenario: RecordedScenario) { const auth = authLayer(scenario) - const provider = Provider.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(auth), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelsDev.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - ) // Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real. const metadata = { provider: scenario.providerID, @@ -293,21 +283,11 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { redactor: HttpRecorderInternal.Redactor.make(redact), }) : HttpRecorder.http(scenario.cassette, { directory: KILO_FIXTURES_DIR, metadata, redact }) // kilocode_change - const recordedClient = LLMClient.layer.pipe( - Layer.provide(Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer)), - ) - - return Layer.mergeAll( - provider, - LLM.layer.pipe( - Layer.provide(auth), - Layer.provide(Config.defaultLayer), - Layer.provide(provider), - Layer.provide(Plugin.defaultLayer), - Layer.provide(recordedClient), - Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })), - ), - ) + return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [ + [LayerNodePlatform.requestExecutor, RequestExecutor.layer.pipe(Layer.provide(recordedHttp))], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: true })], + ...(auth ? ([[Auth.node, auth]] as const) : []), + ]) } const writeConfig = (directory: string, scenario: RecordedScenario, model: ModelsDev.Provider["models"][string]) => diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 702bb67e390..dd4d9cc1748 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -3,6 +3,7 @@ import { LLMEvent, ToolFailure } from "@opencode-ai/llm" import { LLMClient, RequestExecutor, WebSocketExecutor, type LLMClientShape } from "@opencode-ai/llm/route" import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { Effect, Fiber, Layer, Stream } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { LLMNative } from "@/session/llm/native-request" import { LLMNativeRuntime } from "@/session/llm/native-runtime" import type { Provider } from "@/provider/provider" @@ -73,7 +74,11 @@ const providerInfo: Provider.Info = { } const it = testEffect( - LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))), + LLMClient.layer.pipe( + Layer.provide( + Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(FetchHttpClient.layer)), WebSocketExecutor.layer), + ), + ), ) function responsesStream(chunks: unknown[]) { @@ -115,10 +120,9 @@ const storedSession = { const openAIResponses = { user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }), assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }), - openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({ + openaiReasoning: (text: string, encryptedContent: string) => ({ type: "reasoning", - id: options.itemId, - encrypted_content: options.encryptedContent, + encrypted_content: encryptedContent, summary: [{ type: "summary_text", text }], }), } @@ -657,10 +661,7 @@ describe("session.llm-native.request", () => { expectedBody: { input: [ openAIResponses.user("What changed?"), - openAIResponses.openaiReasoning("Checked the previous diff.", { - itemId: "rs_1", - encryptedContent: "encrypted-state", - }), + openAIResponses.openaiReasoning("Checked the previous diff.", "encrypted-state"), openAIResponses.assistant("The parser changed."), openAIResponses.user("Summarize it."), ], @@ -683,7 +684,7 @@ describe("session.llm-native.request", () => { ], providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } }, expectedBody: { - input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }], + input: [{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" }], include: ["reasoning.encrypted_content"], store: false, }, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 7c2df36e619..ec5dea53c44 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -9,13 +9,10 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import z from "zod" import { LLM } from "../../src/session/llm" -import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" -import { Auth } from "@/auth" -import { Config } from "@/config/config" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { Plugin } from "@/plugin" import { testEffect } from "../lib/effect" import type { Agent } from "../../src/agent/agent" @@ -28,6 +25,9 @@ import { Session as SessionNs } from "@/session/session" import { USER_AGENT } from "../../src/installation" // kilocode_change import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +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" type ConfigModel = NonNullable[string]>["models"]>[string] // kilocode_change @@ -53,15 +53,13 @@ const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: stri } } -const it = testEffect(Layer.mergeAll(LLM.defaultLayer, Provider.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([LLM.node, Provider.node]))) // LLM.stream returns a Stream, not an Effect, so we can't use the serviceUse proxy. const drain = (input: LLM.StreamInput) => LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain)) -// drainWith builds an isolated runtime so the custom layer fully owns LLM and -// its transitive deps — `Effect.provide(layer)` over an existing runtime layers -// the new services on top, but transitive Service overrides (e.g. RequestExecutor) -// resolved through the outer LLM.defaultLayer leak through. +// drainWith builds an isolated runtime so custom replacements fully own LLM and +// its transitive deps. const drainWith = (layer: Layer.Layer, input: LLM.StreamInput) => Effect.gen(function* () { const ctx = yield* InstanceRef @@ -76,15 +74,16 @@ const drainWith = (layer: Layer.Layer, input: LLM.StreamInput) => ) }) -function llmLayerWithExecutor(executor: Layer.Layer, flags: Partial = {}) { - return LLM.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(executor, WebSocketExecutor.layer)))), - Layer.provide(RuntimeFlags.layer(flags)), - ) +function llmLayerWithExecutor( + options: { + executor?: Layer.Layer + flags?: Partial + } = {}, +) { + return AppNodeBuilder.build(LLM.node, [ + [RuntimeFlags.node, RuntimeFlags.layer(options.flags)], + ...(options.executor ? ([[LayerNodePlatform.requestExecutor, options.executor]] as const) : []), + ]) } describe("session.llm.hasToolCalls", () => { @@ -1165,14 +1164,10 @@ describe("session.llm.stream", () => { } satisfies Agent.Info yield* drainWith( - LLM.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(failingNativeClient), - Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: false })), - ), + AppNodeBuilder.build(LLM.node, [ + [LayerNodePlatform.llmClient, failingNativeClient], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: false })], + ]), { user: { id: MessageID.make("msg_user-native-flag-off"), @@ -1235,7 +1230,7 @@ describe("session.llm.stream", () => { temperature: 0.2, } satisfies Agent.Info - yield* drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + yield* drainWith(llmLayerWithExecutor({ flags: { experimentalNativeLlm: true } }), { user: { id: MessageID.make("msg_user-native"), sessionID, @@ -1318,7 +1313,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info - yield* drainWith(llmLayerWithExecutor(executor, { experimentalNativeLlm: true }), { + yield* drainWith(llmLayerWithExecutor({ executor, flags: { experimentalNativeLlm: true } }), { user: { id: MessageID.make("msg_user-native-injected-tool"), sessionID, @@ -1350,6 +1345,7 @@ describe("session.llm.stream", () => { type: "function", name: "lookup", description: "Lookup data", + strict: false, parameters: { type: "object", properties: { query: { type: "string" } }, @@ -1406,7 +1402,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info - yield* drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + yield* drainWith(llmLayerWithExecutor({ flags: { experimentalNativeLlm: true } }), { user: { id: MessageID.make("msg_user-native-tool"), sessionID, @@ -1438,6 +1434,7 @@ describe("session.llm.stream", () => { type: "function", name: "lookup", description: "Lookup data", + strict: false, parameters: { type: "object", properties: { query: { type: "string" } }, diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index c0b65b5e57f..b67c982ebcd 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Database } from "@opencode-ai/core/database/database" -import { Effect, Layer, Option } from "effect" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Effect, Option } from "effect" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -11,7 +12,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node]))) const withSession = ( fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 71309ca5e42..3f3b05273ff 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -26,7 +26,6 @@ import { raw, reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionProjector } from "@opencode-ai/core/session/projector" import { LLMEvent } from "@opencode-ai/llm" @@ -179,23 +178,24 @@ const root = LayerNode.group([ CrossSpawnSpawner.node, ]) const replacements = [ - LayerNode.replace(SessionSummary.node, summary), - LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), -] -const env = LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), { replacements }) + [SessionSummary.node, summary], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], +] as const +const env = LayerNode.compile( + LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), + replacements, +) const it = testEffect(env) // kilocode_change start - exercise non-default output token ceilings in the processor const capped = testEffect( - LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), { - replacements: [ - LayerNode.replace(SessionSummary.node, summary), - LayerNode.replace( - RuntimeFlags.node, - RuntimeFlags.layer({ experimentalEventSystem: true, outputTokenMax: 8_000 }), - ), + LayerNode.compile( + LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), + [ + [SessionSummary.node, summary], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true, outputTokenMax: 8_000 })], ], - }), + ), ) // kilocode_change end @@ -219,9 +219,7 @@ const providerErrorLLM = Layer.succeed( ), }), ) -const providerErrorEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.node, providerErrorLLM)], -}) +const providerErrorEnv = LayerNode.compile(root, [...replacements, [LLM.node, providerErrorLLM]]) const itProviderError = testEffect(providerErrorEnv) // kilocode_change start @@ -248,9 +246,7 @@ const lateToolInputLLM = Layer.succeed( ), }), ) -const lateToolInputEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.node, lateToolInputLLM)], -}) +const lateToolInputEnv = LayerNode.compile(root, [...replacements, [LLM.node, lateToolInputLLM]]) const itLateToolInput = testEffect(lateToolInputEnv) // kilocode_change end @@ -268,9 +264,7 @@ const fragmentFailureLLM = Layer.succeed( ), }), ) -const fragmentFailureEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.node, fragmentFailureLLM)], -}) +const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]]) const itFragmentFailure = testEffect(fragmentFailureEnv) const boot = Effect.fn("test.boot")(function* () { @@ -1071,10 +1065,9 @@ itProviderError.live("session.processor effect tests fail provider-executed erro const parent = yield* user(chat.id, "provider tool error") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const mdl = yield* provider.getModel(ref.providerID, ref.modelID) - const settlements: Array = [] + const seen: string[] = [] const off = yield* events.listen((event) => { - if (event.type === SessionEvent.Tool.Failed.type) - settlements.push(event as typeof SessionEvent.Tool.Failed.Type) + seen.push(event.type) return Effect.void }) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl }) @@ -1101,13 +1094,9 @@ itProviderError.live("session.processor effect tests fail provider-executed erro const call = parts.find((part): part is SessionV1.ToolPart => part.type === "tool") expect(call?.state.status).toBe("error") if (call?.state.status === "error") expect(call.state.error).toBe("provider boom") - expect(settlements).toHaveLength(1) - expect(settlements[0]?.data).toMatchObject({ - callID: "call-1", - error: { type: "unknown", message: "provider boom" }, - result: { type: "error", value: "provider boom" }, - provider: { executed: true }, - }) + expect(seen).toContain(MessageV2.Event.PartUpdated.type) + expect(seen).toContain(MessageV2.Event.Updated.type) + expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) }), { config: cfg }, ), @@ -1155,7 +1144,7 @@ itLateToolInput.live("session.processor effect tests ignore tool input after the ) // kilocode_change end -itFragmentFailure.live("session.processor effect tests flush partial v2 fragments before step failure", () => +itFragmentFailure.live("session.processor effect tests retain partial legacy parts without v2 events", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { @@ -1167,14 +1156,8 @@ itFragmentFailure.live("session.processor effect tests flush partial v2 fragment const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const mdl = yield* provider.getModel(ref.providerID, ref.modelID) const seen: string[] = [] - let text: string | undefined - let reasoning: string | undefined const off = yield* events.listen((event) => { seen.push(event.type) - if (event.type === SessionEvent.Text.Ended.type) - text = (event.data as typeof SessionEvent.Text.Ended.data.Type).text - if (event.type === SessionEvent.Reasoning.Ended.type) - reasoning = (event.data as typeof SessionEvent.Reasoning.Ended.data.Type).text return Effect.void }) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl }) @@ -1199,12 +1182,16 @@ itFragmentFailure.live("session.processor effect tests flush partial v2 fragment ).toBe("stop") yield* off - const failed = seen.indexOf(SessionEvent.Step.Failed.type) - expect(failed).toBeGreaterThan(-1) - expect(seen.indexOf(SessionEvent.Text.Ended.type)).toBeLessThan(failed) - expect(seen.indexOf(SessionEvent.Reasoning.Ended.type)).toBeLessThan(failed) - expect(text).toBe("partial") - expect(reasoning).toBe("thinking") + const parts = yield* MessageV2.parts(msg.id) + expect(parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text", text: "partial" }), + expect.objectContaining({ type: "reasoning", text: "thinking" }), + ]), + ) + expect(seen).toContain(MessageV2.Event.PartUpdated.type) + expect(seen).toContain(Session.Event.Error.type) + expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) }), { config: cfg }, ), @@ -1246,56 +1233,58 @@ const sendFileDeliveryLLM = Layer.succeed( }, }), ) -const sendFileDeliveryEnv = LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), { - replacements: [...replacements, LayerNode.replace(LLM.node, sendFileDeliveryLLM)], -}) +const sendFileDeliveryEnv = LayerNode.compile( + LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), + [...replacements, [LLM.node, sendFileDeliveryLLM]], +) const itSendFileDelivery = testEffect(sendFileDeliveryEnv) itSendFileDelivery.live("session.processor preserves send_file delivery attachments without normalization", () => provideTmpdirServer( ({ dir }) => - Effect.gen(function* () { - const { processors, session, provider } = yield* boot() - const chat = yield* session.create({}) - const parent = yield* user(chat.id, "send file") - const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const mdl = yield* provider.getModel(ref.providerID, ref.modelID) - const handle = yield* processors.create({ - assistantMessage: msg, - sessionID: chat.id, - model: mdl, - }) - yield* handle.process({ - user: { - id: parent.id, + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "send file") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, sessionID: chat.id, - role: "user", - time: parent.time, - agent: parent.agent, - model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies SessionV1.User, - sessionID: chat.id, - model: mdl, - agent: agent(), - system: [], - messages: [{ role: "user", content: "send file" }], - tools: {}, - }) - const parts = yield* MessageV2.parts(msg.id) - const toolPart = parts.find( - (part): part is Extract => part.type === "tool" && part.tool === "send_file", - ) - if (!toolPart || toolPart.state.status !== "completed") { - return yield* Effect.fail(new Error("expected completed send_file tool part")) - } - expect(toolPart.state.output).not.toContain("omitted") - expect(toolPart.state.attachments).toHaveLength(1) - expect(toolPart.state.attachments?.[0]).toMatchObject({ - mime: "image/png", - filename: "big.png", - url: `data:image/png;base64,${"x".repeat(6 * 1024 * 1024)}`, - }) - }), + model: mdl, + }) + yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "send file" }], + tools: {}, + }) + const parts = yield* MessageV2.parts(msg.id) + const toolPart = parts.find( + (part): part is Extract => + part.type === "tool" && part.tool === "send_file", + ) + if (!toolPart || toolPart.state.status !== "completed") { + return yield* Effect.fail(new Error("expected completed send_file tool part")) + } + expect(toolPart.state.output).not.toContain("omitted") + expect(toolPart.state.attachments).toHaveLength(1) + expect(toolPart.state.attachments?.[0]).toMatchObject({ + mime: "image/png", + filename: "big.png", + url: `data:image/png;base64,${"x".repeat(6 * 1024 * 1024)}`, + }) + }), { config: (url: string) => providerCfg(url) }, ), ) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 7f1c5f4d266..a722b1f0500 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,7 +1,9 @@ -import { NodeFileSystem } from "@effect/platform-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { NodeFileSystem } from "@effect/platform-node" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" import { Bus } from "@/bus" @@ -38,6 +40,7 @@ import { SessionCompaction } from "../../src/session/compaction" import { SessionSummary } from "../../src/session/summary" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" +import { SessionProjector } from "@opencode-ai/core/session/projector" // kilocode_change import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" @@ -69,6 +72,7 @@ import { MemoryService } from "@kilocode/kilo-memory/effect/service" import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" const summary = Layer.succeed( SessionSummary.Service, @@ -121,28 +125,32 @@ function errorTool(parts: SessionV1.Part[]) { return part?.state.status === "error" ? (part as ErrorToolPart) : undefined } -const mcp = Layer.succeed( - MCP.Service, - MCP.Service.of({ - status: () => Effect.succeed({}), - clients: () => Effect.succeed({}), - tools: () => Effect.succeed({}), - prompts: () => Effect.succeed({}), - resources: () => Effect.succeed({}), - add: () => Effect.succeed({ status: { status: "disabled" as const } }), - connect: () => Effect.void, - disconnect: () => Effect.void, - getPrompt: () => Effect.succeed(undefined), - readResource: () => Effect.succeed(undefined), - startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - removeAuth: () => Effect.void, - supportsOAuth: () => Effect.succeed(false), - hasStoredTokens: () => Effect.succeed(false), - getAuthStatus: () => Effect.succeed("not_authenticated" as const), - }), -) +function makeMcp(instructions: MCP.ServerInstructions[] = []) { + return Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + instructions: () => Effect.succeed(instructions), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), + ) +} const lsp = Layer.succeed( LSP.Service, @@ -164,10 +172,10 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) - +// kilocode_change start - one compiled graph per env. Effect v4 does not memoize nested layers, so +// LayerNode.compile's cache is the only dedupe; building services with separate AppNodeBuilder.build +// calls gave this file three Database instances and every prompt died with "Session not found". +// Mirrors upstream's harness, with Kilo's KiloSessions/MemoryService/fastAgents deltas. const agent: AgentSvc.Info = { name: "build", mode: "primary", @@ -197,85 +205,93 @@ const blockingProcessor = Layer.succeed( }), ) +const runtimeFlags = RuntimeFlags.layer({ experimentalEventSystem: true }) + +const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const testLLMServerNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) + +const promptRoot = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memoryNode, +]) + function makePrompt(input?: { processor?: "blocking" }) { - const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - input?.processor === "blocking" ? fastAgents : AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - ProviderSvc.defaultLayer, - lsp, - mcp, - FSUtil.defaultLayer, - BackgroundJob.defaultLayer, - status, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - Bus.layer, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(RepositoryCache.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provide(Auth.defaultLayer), - Layer.provide(KiloSessions.testLayer), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = - input?.processor === "blocking" - ? blockingProcessor - : SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - ) - const compact = SessionCompaction.layer.pipe( - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(proc), - Layer.provideMerge(deps), - ) - return SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - Layer.provide(summary), - ) + const replacements = [ + [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, makeMcp()], + [RuntimeFlags.node, runtimeFlags], + [KiloSessions.node, KiloSessions.testLayer], + ] as const + if (input?.processor === "blocking") { + return LayerNode.compile(promptRoot, [ + ...replacements, + [SessionProcessor.node, blockingProcessor], + [AgentSvc.node, fastAgents], + ]) + } + return LayerNode.compile(promptRoot, replacements) } function makeHttp(input?: { processor?: "blocking" }) { - return Layer.mergeAll(TestLLMServer.layer, makePrompt(input)) + const root = LayerNode.group([promptRoot, testLLMServerNode]) + const replacements = [ + [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, makeMcp()], + [RuntimeFlags.node, runtimeFlags], + [KiloSessions.node, KiloSessions.testLayer], + ] as const + if (input?.processor === "blocking") { + return LayerNode.compile(root, [ + ...replacements, + [SessionProcessor.node, blockingProcessor], + [AgentSvc.node, fastAgents], + ]) + } + return LayerNode.compile(root, replacements) } function makeHttpNoLLMServer(input?: { processor?: "blocking" }) { return makePrompt(input) } +// kilocode_change end const it = testEffect(makeHttp()) const noLLMServer = testEffect(makeHttpNoLLMServer()) @@ -787,10 +803,16 @@ noLLMServer.instance.skip( ], }) + // kilocode_change start - compile the v2 reader against this test's database graph const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( - Effect.provide(SessionExecution.noopLayer), - Effect.provide(SessionV2.defaultLayer), + Effect.provide( + LayerNode.compile(SessionV2.node, [ + [SessionExecution.node, SessionExecution.noopLayer], + [LocationServiceMap.node, locationServiceMapLayer], + ]), + ), ) + // kilocode_change end const { db } = yield* Database.Service const row = yield* db .select() @@ -1497,6 +1519,7 @@ unix( const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* llm.wait(1) + yield* waitForBusy(chat.id) yield* prompt.cancel(chat.id) const exit = yield* Fiber.await(fiber) expect(Exit.isSuccess(exit)).toBe(true) @@ -1814,78 +1837,77 @@ it.instance( 10_000, ) -it.instance( - "prompt submitted during an active run is included in the next LLM input", - () => - Effect.gen(function* () { - const { llm } = yield* useServerConfig(providerCfg) - const gate = yield* Deferred.make() - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) +it.instance("prompt submitted during an active run is included in the next LLM input", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const gate = yield* Deferred.make() + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hold("first", deferredAsPromise(gate)) - yield* llm.text("second") + yield* llm.hold("first", deferredAsPromise(gate)) + yield* llm.text("second") - const a = yield* prompt - .prompt({ - sessionID: chat.id, - agent: "build", - model: ref, - parts: [{ type: "text", text: "first" }], - }) - .pipe(Effect.forkChild) - - yield* llm.wait(1) - - const id = MessageID.ascending() - const b = yield* prompt - .prompt({ - sessionID: chat.id, - messageID: id, - agent: "build", - model: ref, - parts: [{ type: "text", text: "second" }], - }) - .pipe(Effect.forkChild) - - yield* pollWithTimeout( - sessions - .messages({ sessionID: chat.id }) - .pipe( - Effect.map((msgs) => - msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined, - ), - ), - "timed out waiting for second prompt to save", - ) - - yield* Deferred.succeed(gate, void 0) - - const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isSuccess(ea)).toBe(true) - expect(Exit.isSuccess(eb)).toBe(true) - expect(yield* llm.calls).toBe(2) - - const msgs = yield* sessions.messages({ sessionID: chat.id }) - const assistants = msgs.filter((msg) => msg.info.role === "assistant") - expect(assistants).toHaveLength(2) - const last = assistants.at(-1) - if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") - expect(last.info.parentID).toBe(id) - expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) - - const inputs = yield* llm.inputs - expect(inputs).toHaveLength(2) - const messages = inputs.at(-1)?.messages - if (!Array.isArray(messages)) throw new Error("expected LLM messages") - // kilocode_change start - Kilo appends environment details to queued user prompts - expect(messages.at(-1)).toMatchObject({ - role: "user", - content: expect.arrayContaining([{ type: "text", text: "second" }]), + const a = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "first" }], }) - // kilocode_change end - }), + .pipe(Effect.forkChild) + + yield* llm.wait(1) + yield* waitForBusy(chat.id) + + const id = MessageID.ascending() + const b = yield* prompt + .prompt({ + sessionID: chat.id, + messageID: id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "second" }], + }) + .pipe(Effect.forkChild) + + yield* pollWithTimeout( + sessions + .messages({ sessionID: chat.id }) + .pipe( + Effect.map((msgs) => + msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined, + ), + ), + "timed out waiting for second prompt to save", + ) + + yield* Deferred.succeed(gate, void 0) + + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(ea)).toBe(true) + expect(Exit.isSuccess(eb)).toBe(true) + expect(yield* llm.calls).toBe(2) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + expect(assistants).toHaveLength(2) + const last = assistants.at(-1) + if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") + expect(last.info.parentID).toBe(id) + expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(2) + const messages = inputs.at(-1)?.messages + if (!Array.isArray(messages)) throw new Error("expected LLM messages") + // kilocode_change start - Kilo appends environment details to queued user prompts + expect(messages.at(-1)).toMatchObject({ + role: "user", + content: expect.arrayContaining([{ type: "text", text: "second" }]), + }) + // kilocode_change end + }), 10_000, ) @@ -1944,6 +1966,7 @@ it.instance( const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* llm.wait(1) + yield* waitForBusy(chat.id) const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -2068,9 +2091,16 @@ unixNoLLMServer( const tool = completedTool(result.parts) if (!tool) return + // kilocode_change start - bind v2 execution and location services in the consolidated test graph const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( - Effect.provide(SessionV2.defaultLayer), + Effect.provide( + LayerNode.compile(SessionV2.node, [ + [SessionExecution.node, SessionExecution.noopLayer], + [LocationServiceMap.node, locationServiceMapLayer], + ]), + ), ) + // kilocode_change end const shell = messages.find((message) => message.type === "shell") expect(shell).toMatchObject({ @@ -2276,11 +2306,17 @@ unixNoLLMServer( withSh(() => Effect.gen(function* () { const { prompt, run, chat } = yield* boot() + const { directory: dir } = yield* TestInstance + const afs = yield* FSUtil.Service + const ready = path.join(dir, ".shell-ready") const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .shell({ sessionID: chat.id, agent: "build", command: ": > '.shell-ready'; sleep 30" }) .pipe(Effect.forkChild) - yield* waitForBusy(chat.id) + yield* pollWithTimeout( + afs.existsSafe(ready).pipe(Effect.map((exists) => (exists ? (true as const) : undefined))), + "shell never created readiness marker", + ) yield* prompt.cancel(chat.id) @@ -2370,7 +2406,6 @@ unix( yield* llm.tool("bash", { command: 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30', - description: "Print many lines", timeout: 30_000, workdir: path.resolve(dir), }) @@ -2864,6 +2899,7 @@ it.instance( .pipe(Effect.forkChild) yield* llm.wait(1) + yield* waitForBusy(session.id) yield* prompt.cancel(session.id) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 54b161c06a8..604f21caf60 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Layer, Schedule, Schema } from "effect" +import { Effect, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -15,7 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" const providerID = ProviderV2.ID.make("test") const retryProvider = "test" -const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node]))) function apiError(headers?: Record): SessionV1.APIError { return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 4e71a6a36c5..d3d7ba7aae9 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -1,29 +1,28 @@ import { describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { Session } from "@/session/session" import { SessionRevert } from "../../src/session/revert" import { MessageV2 } from "../../src/session/message-v2" import { Snapshot } from "../../src/snapshot" import { MessageID, PartID, SessionID } from "../../src/session/schema" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const env = Layer.mergeAll( - Session.defaultLayer, - SessionRevert.defaultLayer, - Snapshot.defaultLayer, - CrossSpawnSpawner.defaultLayer, +const it = testEffect( + LayerNode.compile( + LayerNode.group([Session.node, SessionRevert.node, Snapshot.node, SessionProjector.node, CrossSpawnSpawner.node]), + ), ) -const it = testEffect(env) - const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") { const session = yield* Session.Service return yield* session.updateMessage({ diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index ba4acce40e5..2b8e08696f0 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -256,7 +256,7 @@ describe("Todo.Info", () => { const decode = decodeUnknown(Todo.Info) test("three-field round-trip", () => { - const input = { content: "do a thing", status: "pending", priority: "high" } + const input = Todo.Info.make({ content: "do a thing", status: "pending", priority: "high" }) expect(decode(input)).toEqual(input) }) }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index dafc8135f04..10603801456 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,6 +1,5 @@ import { describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Layer } from "effect" @@ -9,26 +8,32 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" type SessionModel = NonNullable // kilocode_change import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" -import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" import { GlobalBus } from "@/bus/global" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" const it = testEffect( - Layer.mergeAll( - SessionNs.layer.pipe( - Layer.provide(Storage.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - Layer.provide(SessionProjector.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), - Layer.provide(BackgroundJob.defaultLayer), - ), - CrossSpawnSpawner.defaultLayer, - testInstanceStoreLayer, + AppNodeBuilder.build( + LayerNode.group([ + SessionNs.node, + EventV2Bridge.node, + SessionProjector.node, + CrossSpawnSpawner.node, + InstanceStore.node, + ]), + [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })], + [ + InstanceBootstrap.node, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], + ], ), ) diff --git a/packages/opencode/test/session/shell-v2.test.ts b/packages/opencode/test/session/shell-v2.test.ts index e9399d4c1a2..7d341fb7929 100644 --- a/packages/opencode/test/session/shell-v2.test.ts +++ b/packages/opencode/test/session/shell-v2.test.ts @@ -6,7 +6,7 @@ import { SessionID } from "../../src/session/schema" import { EventV2 } from "@opencode-ai/core/event" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" -import { SessionMessageID } from "@opencode-ai/core/session/message-id" +import { SessionMessage } from "@opencode-ai/core/session/message" describe("v2 shell event correlation", () => { test("an unmatched end is ignored before a matching start and end complete one record", () => { @@ -34,7 +34,7 @@ describe("v2 shell event correlation", () => { data: { sessionID, timestamp: DateTime.makeUnsafe(1), - messageID: SessionMessageID.ID.create(), + messageID: SessionMessage.ID.create(), callID, command: "pwd", }, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 8a3701e1251..1265237840f 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -37,9 +37,11 @@ const mcp = Layer.succeed( MCP.Service.of({ status: () => Effect.succeed({}), clients: () => Effect.succeed({}), + instructions: () => Effect.succeed([]), tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, @@ -82,16 +84,14 @@ const root = LayerNode.group([ SessionSummary.node, Database.node, CrossSpawnSpawner.node, - LayerNode.make(TestLLMServer.layer, []), + LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }), ]) const it = testEffect( - LayerNode.buildLayer(root, { - replacements: [ - LayerNode.replace(MCP.node, mcp), - LayerNode.replace(LSP.node, lsp), - LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), - ], - }), + LayerNode.compile(root, [ + [MCP.node, mcp], + [LSP.node, lsp], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], + ]), ) const providerCfg = (url: string) => ({ @@ -139,7 +139,6 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", { command, - description: "create test file", }) yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index 319b3bd728e..df5755f89f5 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { Effect, Layer } from "effect" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import { MessageV2 } from "../../src/session/message-v2" @@ -9,9 +11,7 @@ import { testEffect } from "../lib/effect" // Skip tests if no API key is available const hasApiKey = !!process.env.ANTHROPIC_API_KEY -const it = testEffect( - Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)), -) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([SessionPrompt.node, Session.node, Ripgrep.node]))) const live = hasApiKey ? it.instance : it.instance.skip describe("StructuredOutput Integration", () => { diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 951fc7204b2..0014747b175 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import type { Agent } from "../../src/agent/agent" @@ -5,7 +6,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { testEffect } from "../lib/effect" import { Config } from "../../src/config/config" // kilocode_change @@ -43,10 +44,9 @@ const build: Agent.Info = { } const it = testEffect( - SystemPrompt.layer.pipe( - Layer.provide(LocationServiceMap.layer), - Layer.provide(Config.defaultLayer), // kilocode_change - Layer.provide( + AppNodeBuilder.build(SystemPrompt.node, [ + [ + Skill.node, Layer.succeed( Skill.Service, Skill.Service.of({ @@ -61,8 +61,8 @@ const it = testEffect( available: () => Effect.succeed(skills), }), ), - ), - ), + ], + ]), ) describe("session.system", () => { diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index ccc21f2ee22..97e35bfd7b5 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect } from "bun:test" import { Effect, Exit, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -19,7 +19,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" import { pollWithTimeout, testEffect } from "../lib/effect" -const env = LayerNode.buildLayer(CrossSpawnSpawner.node) +const env = LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node])) const it = testEffect(env) const json = (req: Parameters[0], body: unknown, status = 200) => @@ -34,13 +34,13 @@ const json = (req: Parameters[0], body: unkno const none = HttpClient.make(() => Effect.die("unexpected http call")) function requestLayer(client: HttpClient.HttpClient) { - return LayerNode.buildLayer(LayerNode.group([ShareNext.node, AccountRepo.node]), { - replacements: [LayerNode.replace(httpClient, Layer.succeed(HttpClient.HttpClient, client))], - }) + const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const + return LayerNode.compile(LayerNode.group([ShareNext.node, AccountRepo.node]), [replacement]) } function integrationLayer(client: HttpClient.HttpClient) { - return LayerNode.buildLayer( + const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const + return LayerNode.compile( LayerNode.group([ ShareNext.node, EventV2Bridge.node, @@ -49,9 +49,7 @@ function integrationLayer(client: HttpClient.HttpClient) { AccountRepo.node, Database.node, ]), - { - replacements: [LayerNode.replace(httpClient, Layer.succeed(HttpClient.HttpClient, client))], - }, + [replacement], ) } diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index f454a6b3a7d..5bd82e8fe20 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, beforeAll, afterAll } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Discovery } from "../../src/skill/discovery" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" @@ -11,10 +12,14 @@ import { testEffect } from "../lib/effect" let CLOUDFLARE_SKILLS_URL: string let server: ReturnType let downloadCount = 0 +let mutableVersion = "1" +let mutableContent = "# Old" +let mutableDownloadCount = 0 +let mutableFiles = ["SKILL.md"] const fixturePath = path.join(import.meta.dir, "../fixture/skills") const cacheDir = path.join(Global.Path.cache, "skills") -const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Discovery.node, FSUtil.node]))) beforeAll(async () => { await rm(cacheDir, { recursive: true, force: true }) @@ -24,6 +29,14 @@ beforeAll(async () => { async fetch(req) { const url = new URL(req.url) + if (url.pathname === "/mutable/index.json") { + return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] }) + } + if (url.pathname === "/mutable/mutable/SKILL.md") { + mutableDownloadCount++ + return new Response(mutableContent) + } + if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference") // kilocode_change start - serve a crafted index whose skill name escapes the cache via `../` if (url.pathname === "/evil/index.json") { return Response.json({ skills: [{ name: "../../../.agents/skills/evil", files: ["SKILL.md"] }] }) @@ -172,4 +185,37 @@ describe("Discovery.pull", () => { expect(downloadCount).toBe(firstCount) }), ) + + it.live("refreshes a remote skill when its version changes", () => + Effect.gen(function* () { + yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true })) + mutableVersion = "1" + mutableContent = "# Old" + mutableDownloadCount = 0 + mutableFiles = ["SKILL.md", "old.md"] + const discovery = yield* Discovery.Service + const url = `http://localhost:${server.port}/mutable/` + + const first = yield* discovery.pull(url) + expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old") + + mutableVersion = "2" + mutableContent = "# Partial" + mutableFiles = ["SKILL.md", "missing.md"] + const second = yield* discovery.pull(url) + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old") + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference") + + mutableVersion = "3" + mutableContent = "# New" + mutableFiles = ["SKILL.md"] + yield* discovery.pull(url) + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New") + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false) + expect(mutableDownloadCount).toBe(3) + + yield* discovery.pull(url) + expect(mutableDownloadCount).toBe(3) + }), + ) }) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index fe5d5925d6f..a758782cc18 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" @@ -14,18 +15,12 @@ import { testEffect } from "../lib/effect" import path from "path" import fs from "fs/promises" -const node = CrossSpawnSpawner.defaultLayer +const node = AppNodeBuilder.build(CrossSpawnSpawner.node) const skills = (disableExternalSkills: boolean, disableClaudeCodeSkills: boolean) => - Skill.layer.pipe( - Layer.provide(Git.defaultLayer), // kilocode_change - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.layer({ disableExternalSkills, disableClaudeCodeSkills })), - ) + AppNodeBuilder.build(Skill.node, [ + [RuntimeFlags.node, RuntimeFlags.layer({ disableExternalSkills, disableClaudeCodeSkills })], + ]) const it = testEffect(Layer.mergeAll(skills(false, false), node, testInstanceStoreLayer)) const itWithoutExternalSkills = testEffect(Layer.mergeAll(skills(true, false), node, testInstanceStoreLayer)) @@ -66,6 +61,33 @@ const discovered = (list: readonly T[]) => list.filter((skill) => ![Skill.BUILTIN_LOCATION, ""].includes(skill.location)) // kilocode_change describe("skill", () => { + it.effect("formats verbose locations as XML-safe filesystem paths", () => + Effect.sync(() => { + const output = Skill.fmt( + [ + { + name: "tagged-skill", + description: "A tagged skill.", + location: "/tmp/plugin.git#v1.3.0/SKILL.md", + content: "", + }, + { + name: "built-in-skill", + description: "A built-in skill.", + location: "", + content: "", + }, + ], + { verbose: true }, + ) + + expect(output).toContain("/tmp/plugin.git#v1.3.0/SKILL.md") + expect(output).toContain("<built-in>") + expect(output).not.toContain("file://") + expect(output).not.toContain("%23") + }), + ) + // kilocode_change start it.live("discovers skills from .kilo/skill/ directory", () => // kilocode_change end diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 208bc0e1699..a39624087c6 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect } from "bun:test" import { $ } from "bun" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import fs from "fs/promises" import path from "path" @@ -15,7 +16,11 @@ import { } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([Snapshot.node, FSUtil.node])), testInstanceStoreLayer), +) +// Windows forbids both * and : in directory names. +const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -72,11 +77,12 @@ const withTrackedSnapshot = ( }) const bootstrapScoped = Effect.fn("SnapshotTest.bootstrapScoped")(function* () { - const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) + const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))) return { path: dir, extra: yield* initialize(dir) } }) -const scopedGitTmpdir = () => tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) +const scopedGitTmpdir = () => + tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))) const cleanupWorktree = (repo: string, worktree: string, files: string[] = []) => Effect.promise(async () => { @@ -448,6 +454,97 @@ it.live( }), ) +it.live( + "subdirectory snapshots include scoped changes only", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const frontend = path.join(dir, "frontend") + yield* write(`${frontend}/tracked.txt`, "initial") + yield* write(`${frontend}/deleted.txt`, "initial") + yield* write(`${dir}/backend/tracked.txt`, "initial") + yield* write(`${dir}/backend/deleted.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${frontend}/tracked.txt`, "changed") + yield* write(`${frontend}/untracked.txt`, "new") + yield* rm(`${frontend}/deleted.txt`) + yield* write(`${dir}/backend/tracked.txt`, "changed") + yield* rm(`${dir}/backend/deleted.txt`) + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(frontend, "tracked.txt")) + expect(patch.files).toContain(fwd(frontend, "untracked.txt")) + expect(patch.files).toContain(fwd(frontend, "deleted.txt")) + expect(patch.files).not.toContain(fwd(dir, "backend", "tracked.txt")) + expect(patch.files).not.toContain(fwd(dir, "backend", "deleted.txt")) + expect(diff).not.toContain("backend/tracked.txt") + expect(diff).not.toContain("backend/deleted.txt") + }).pipe(provideInstance(frontend)) + }), +) + +nonWindowsIt( + "subdirectory snapshots treat wildcard characters literally", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const subdir = path.join(dir, "src*") + yield* write(`${subdir}/file.txt`, "initial") + yield* write(`${subdir}/later-ignored.txt`, "initial") + yield* write(`${dir}/srca/file.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/file.txt`, "changed") + yield* write(`${subdir}/later-ignored.txt`, "changed") + yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n") + yield* write(`${dir}/srca/file.txt`, "changed") + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(subdir, "file.txt")) + expect(patch.files).toContain(fwd(subdir, ".gitignore")) + expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt")) + expect(patch.files).not.toContain(fwd(dir, "srca", "file.txt")) + expect(diff).toContain("src*/later-ignored.txt") + expect(diff).toContain("deleted file mode") + expect(diff).not.toContain("srca/file.txt") + }).pipe(provideInstance(subdir)) + }), +) + +nonWindowsIt( + "subdirectory snapshots treat leading colons literally", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const subdir = path.join(dir, ":src") + yield* write(`${subdir}/kept.txt`, "initial") + yield* write(`${subdir}/later-ignored.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/kept.txt`, "changed") + yield* write(`${subdir}/later-ignored.txt`, "changed") + yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n") + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(subdir, "kept.txt")) + expect(patch.files).toContain(fwd(subdir, ".gitignore")) + expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt")) + expect(diff).toContain(":src/later-ignored.txt") + expect(diff).toContain("deleted file mode") + }).pipe(provideInstance(subdir)) + }), +) + it.instance( "gitignore changes", withTrackedSnapshot(({ tmp, snapshot, before }) => diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index afb2e93755e..a3f3aef4464 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Exit, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -11,7 +12,7 @@ import { testEffect } from "../lib/effect" const dir = path.join(Global.Path.data, "storage") -const it = testEffect(Layer.mergeAll(Storage.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Storage.node, FSUtil.node, CrossSpawnSpawner.node]))) const scope = Effect.fnUntraced(function* () { const root = ["storage_test", crypto.randomUUID()] @@ -50,14 +51,14 @@ function remappedFs(root: string) { fs.glob(pattern, options?.cwd ? { ...options, cwd: remap(root, options.cwd) } : options), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } // Layer.fresh forces a new Storage instance — without it, Effect's in-test layer cache // returns the outer testEffect's Storage (which uses the real FSUtil), not a new // one built on top of remappedFs. const remappedStorage = (root: string) => - Layer.fresh(Storage.layer.pipe(Layer.provide(remappedFs(root)), Layer.provide(Git.defaultLayer))) + Layer.fresh(LayerNode.compile(Storage.node, [[FSUtil.node, remappedFs(root)]])) describe("Storage", () => { it.live("round-trips JSON content", () => diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 7253603911f..cf1a30910ff 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import path from "path" import * as fs from "fs/promises" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { LSP } from "@/lsp/lsp" @@ -14,13 +15,8 @@ import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - Format.defaultLayer, - EventV2Bridge.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, + LayerNode.compile( + LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node]), ), ) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 12db5355185..46155a50918 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { EditTool } from "../../src/tool/edit" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -30,13 +31,8 @@ afterEach(async () => { await disposeAllInstances() }) -const layer = Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - Format.defaultLayer, - EventV2Bridge.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, +const layer = LayerNode.compile( + LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node]), ) const it = testEffect(layer) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 1516d0ba7c4..a5c7b157e53 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,4 +1,5 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect } from "bun:test" import path from "path" import { Effect } from "effect" @@ -11,7 +12,7 @@ import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" -const it = testEffect(CrossSpawnSpawner.defaultLayer) +const it = testEffect(LayerNode.compile(CrossSpawnSpawner.node)) const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index d52747b6ac9..21b46b2c167 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -1,3 +1,4 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect } from "bun:test" import path from "path" // kilocode_change start @@ -20,12 +21,12 @@ import { Filesystem } from "@/util/filesystem" const toolLayer = (flags: Partial = {}) => Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - Git.defaultLayer, + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + AppNodeBuilder.build(Ripgrep.node), + AppNodeBuilder.build(Truncate.node), + AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(Git.node), ) const it = testEffect(toolLayer()) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 630c355df31..0c0769d4dab 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -3,6 +3,7 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import os from "os" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" import { provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" @@ -22,13 +23,8 @@ import { Git } from "@/git" import { Filesystem } from "@/util/filesystem" const toolLayer = (flags: Partial = {}) => - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - Git.defaultLayer, + LayerNode.compile( + LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node]), ) const it = testEffect(toolLayer()) diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index ddcf14e9ae3..8735fdb7b29 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" @@ -56,7 +57,9 @@ const lsp = Layer.succeed( ) const it = testEffect( - Layer.mergeAll(Agent.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, lsp), + LayerNode.compile(LayerNode.group([Agent.node, FSUtil.node, CrossSpawnSpawner.node, Truncate.node, LSP.node]), [ + [LSP.node, lsp], + ]), ) const init = Effect.fn("LspToolTest.init")(function* () { diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 531e94f38f7..67247b00b8e 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -106,11 +106,11 @@ describe("tool parameters", () => { }) describe("shell", () => { - test("accepts minimum: command + description", () => { - expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + test("accepts command", () => { + expect(parse(Shell, { command: "ls" })).toEqual({ command: "ls" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) @@ -120,7 +120,7 @@ describe("tool parameters", () => { }) // kilocode_change end test("rejects missing command", () => { - expect(accepts(Shell, { description: "list" })).toBe(false) + expect(accepts(Shell, {})).toBe(false) }) }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 0bbc58d4425..d8c6df77e8a 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -1,10 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Fiber, Layer, Queue } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Fiber, Queue } from "effect" import { QuestionTool } from "../../src/tool/question" import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" import { testEffect } from "../lib/effect" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -21,12 +21,7 @@ const ctx = { } const it = testEffect( - Layer.mergeAll( - Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), - CrossSpawnSpawner.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - ), + LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, Truncate.node, Agent.node])), ) const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index fa0eb41ea32..b2984bc3bca 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer, Stream } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" @@ -44,14 +45,16 @@ const ctx = { } const readLayer = (flags: Partial = {}) => - Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Instruction.defaultLayer, - LSP.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, + LayerNode.compile( + LayerNode.group([ + Agent.node, + FSUtil.node, + CrossSpawnSpawner.node, + Instruction.node, + LSP.node, + Ripgrep.node, + Truncate.node, + ]), ) const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer)) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index cb53301b1b4..f376077fd0c 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -57,14 +57,14 @@ const brokenPluginLayer = Layer.succeed( ) const root = LayerNode.group([ToolRegistry.node, Agent.node]) -const registryLayer = (opts: RegistryLayerOptions = {}) => - LayerNode.buildLayer(root, { - replacements: [ - LayerNode.replace(Config.node, opts.config ? TestConfig.layer(opts.config) : configLayer), // kilocode_change - LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer(opts.flags ?? {})), - ...(opts.plugin ? [LayerNode.replace(Plugin.node, opts.plugin)] : []), - ], - }) +const registryLayer = (opts: RegistryLayerOptions = {}) => { + const replacements = [ + [Config.node, opts.config ? TestConfig.layer(opts.config) : configLayer], // kilocode_change + [RuntimeFlags.node, RuntimeFlags.layer(opts.flags ?? {})], + ] as const + if (!opts.plugin) return LayerNode.compile(root, replacements) + return LayerNode.compile(root, [...replacements, [Plugin.node, opts.plugin] as const]) +} const it = testEffect(registryLayer()) const scout = testEffect(registryLayer({ flags: { experimentalScout: true } })) // kilocode_change diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 1de2fbe2f42..172ba7faa9d 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" import os from "os" @@ -22,13 +23,17 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { InstanceStore } from "@/project/instance-store" const shellLayer = Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - RuntimeFlags.defaultLayer, + LayerNode.compile( + LayerNode.group([ + CrossSpawnSpawner.node, + FSUtil.node, + Plugin.node, + Truncate.node, + Config.node, + Agent.node, + RuntimeFlags.node, + ]), + ), testInstanceStoreLayer, ) const it = testEffect(shellLayer) @@ -182,7 +187,6 @@ describe("tool.shell", () => { Effect.gen(function* () { const result = yield* run({ command: "echo test", - description: "Echo test message", }) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") @@ -204,7 +208,6 @@ describe("tool.shell", () => { const result = yield* bash.execute( { command: "echo fallback", - description: "Echo fallback text", }, ctx, ) @@ -227,7 +230,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo hello", - description: "Echo hello", }, capture(requests), ) @@ -249,7 +251,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo foo && echo bar", - description: "Echo twice", }, capture(requests), ) @@ -273,7 +274,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Host foo; if ($?) { Write-Host bar }", - description: "Check PowerShell conditional", }, capture(requests), ) @@ -303,7 +303,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Remove-Item -Recurse tmp", - description: "Remove a temp directory", }, capture(requests, err), ), @@ -331,7 +330,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${file}`, - description: "Read wildcard path", }, capture(requests, err), ), @@ -359,7 +357,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `echo $(cat "${file}")`, - description: "Read nested bash file", }, capture(requests), ) @@ -389,7 +386,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, - description: "Copy Windows ini", }, capture(requests, err), ), @@ -415,7 +411,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `Write-Output $(Get-Content ${file})`, - description: "Read nested PowerShell file", }, capture(requests), ) @@ -446,7 +441,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "C:../outside.txt"', - description: "Read drive-relative file", }, capture(requests, err), ), @@ -474,7 +468,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$HOME/.ssh/config"', - description: "Read home config", }, capture(requests, err), ), @@ -503,7 +496,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PWD/../outside.txt"', - description: "Read pwd-relative file", }, capture(requests, err), ), @@ -531,7 +523,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PSHOME/outside.txt"', - description: "Read pshome file", }, capture(requests, err), ), @@ -567,7 +558,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, - description: "Read Windows ini with missing env", }, capture(requests, err), ), @@ -598,7 +588,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Get-Content $env:WINDIR/win.ini", - description: "Read Windows ini from env", }, capture(requests), ) @@ -626,7 +615,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, - description: "Read Windows ini from FileSystem provider", }, capture(requests, err), ), @@ -655,7 +643,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Get-Content ${env:WINDIR}/win.ini", - description: "Read Windows ini from braced env", }, capture(requests, err), ), @@ -682,7 +669,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Set-Location C:/Windows", - description: "Change location", }, capture(requests), ) @@ -710,7 +696,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Output ('a' * 3)", - description: "Write repeated text", }, capture(requests), ) @@ -736,7 +721,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, - description: "Read Windows ini with cmd", }, capture(requests), ) @@ -761,7 +745,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cd ../", - description: "Change to parent directory", }, capture(requests, err), ), @@ -786,7 +769,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: os.tmpdir(), - description: "Echo from temp dir", }, capture(requests, err), ), @@ -817,7 +799,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: dir, - description: "Echo from external dir", }, capture(requests, err), ), @@ -850,7 +831,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: "/tmp", - description: "Echo from Git Bash tmp", }, capture(requests, err), ), @@ -878,7 +858,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cat /tmp/opencode-does-not-exist", - description: "Read Git Bash tmp file", }, capture(requests, err), ), @@ -910,7 +889,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${filepath}`, - description: "Read external file", }, capture(requests, err), ), @@ -922,7 +900,6 @@ describe("tool.shell permissions", () => { expect(extDirReq!.always).toContain(expected) expect(extDirReq!.metadata).toMatchObject({ command: `cat ${filepath}`, - description: "Read external file", directories: [outerTmp], patterns: [expected], }) @@ -942,7 +919,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `rm -rf ${path.join(tmp, "nested")}`, - description: "Remove nested dir", }, capture(requests), ) @@ -963,7 +939,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "git log --oneline -5", - description: "Git log", }, capture(requests), ) @@ -985,7 +960,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "cd .", - description: "Stay in current directory", }, capture(requests), ) @@ -1004,12 +978,9 @@ describe("tool.shell permissions", () => { Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - expect( - yield* fail( - { command: "echo test > output.txt", description: "Redirect test output" }, - capture(requests, err), - ), - ).toMatchObject({ message: err.message }) + expect(yield* fail({ command: "echo test > output.txt" }, capture(requests, err))).toMatchObject({ + message: err.message, + }) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("echo test > output.txt") @@ -1025,7 +996,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const requests: Array> = [] - yield* run({ command: "ls -la", description: "List" }, capture(requests)) + yield* run({ command: "ls -la" }, capture(requests)) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() // kilocode_change start — arity prefix produces "ls *" with space before wildcard @@ -1050,7 +1021,6 @@ describe("tool.shell abort", () => { const res = yield* run( { command: `echo before && sleep 30`, - description: "Long running command", }, { ...ctx, @@ -1081,7 +1051,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `sleep 60`, - description: "Timeout test", timeout: 500, }) expect(result.output).toContain("shell tool terminated command after exceeding timeout") @@ -1102,7 +1071,6 @@ describe("tool.shell abort", () => { const result = yield* tool.execute( { command: `sleep 60`, - description: "Default timeout test", }, ctx, ) @@ -1119,7 +1087,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `echo stdout_msg && echo stderr_msg >&2`, - description: "Stderr test", }) expect(result.output).toContain("stdout_msg") expect(result.output).toContain("stderr_msg") @@ -1135,7 +1102,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `exit 42`, - description: "Non-zero exit", }) expect(result.metadata.exit).toBe(42) }), @@ -1150,7 +1116,6 @@ describe("tool.shell abort", () => { const result = yield* run( { command: `echo first && sleep 0.1 && echo second`, - description: "Streaming test", }, { ...ctx, @@ -1177,7 +1142,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 500 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1193,7 +1157,6 @@ describe("tool.shell truncation", () => { const byteCount = Truncate.MAX_BYTES + 10000 const result = yield* run({ command: fill("bytes", byteCount), - description: "Generate bytes exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1208,7 +1171,6 @@ describe("tool.shell truncation", () => { Effect.gen(function* () { const result = yield* run({ command: fill("lines", 1), - description: "Generate one line", }) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) expect(result.output).toContain("1") @@ -1223,7 +1185,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 100 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines for file check", }) mustTruncate(result) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index ac1c8024585..f3875fe1e7a 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,10 +1,10 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Cause, Effect, Exit, Layer } from "effect" import { afterEach, describe, expect } from "bun:test" import path from "path" -import { pathToFileURL } from "url" import type { Permission } from "../../src/permission" import type { Tool } from "@/tool/tool" import { SkillTool } from "../../src/tool/skill" @@ -27,9 +27,7 @@ afterEach(async () => { await disposeAllInstances() }) -const node = CrossSpawnSpawner.defaultLayer - -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node).pipe(Layer.provide(Ripgrep.defaultLayer))) +const it = testEffect(LayerNode.compile(LayerNode.group([ToolRegistry.node, CrossSpawnSpawner.node, Ripgrep.node]))) // kilocode_change - skip on windows: address windows ci failures #9496 const unix = process.platform !== "win32" ? it.instance : it.instance.skip @@ -93,7 +91,7 @@ Use this skill. expect(requests[0].always).toContain("tool-skill") expect(result.metadata.dir).toBe(skill) expect(result.output).toContain(``) - expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`) + expect(result.output).toContain(`Base directory for this skill: ${skill}`) expect(result.output).toContain(`${file}`) }), ) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 306ffb473c6..5f05b95df7e 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" -import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" // kilocode_change - Cause/Deferred for resume-hint coverage +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" // kilocode_change - Cause for resume-hint coverage import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" @@ -35,21 +37,26 @@ const ref = { } const layer = (flags: Partial = {}) => - Layer.mergeAll( - Agent.defaultLayer, - BackgroundJob.defaultLayer, - EventV2Bridge.defaultLayer, - Config.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Session.defaultLayer, - SessionRunState.defaultLayer, - SessionStatus.defaultLayer, - Truncate.defaultLayer, - Provider.defaultLayer, // kilocode_change - ToolRegistry.defaultLayer, - Database.defaultLayer, - RuntimeFlags.layer(flags), - ).pipe(Layer.provide(Ripgrep.defaultLayer)) + LayerNode.compile( + LayerNode.group([ + Agent.node, + BackgroundJob.node, + EventV2Bridge.node, + Config.node, + CrossSpawnSpawner.node, + Session.node, + SessionProjector.node, + SessionRunState.node, + SessionStatus.node, + Truncate.node, + ToolRegistry.node, + Provider.node, // kilocode_change + Database.node, + RuntimeFlags.node, + Ripgrep.node, + ]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], + ) const it = testEffect(layer()) const background = testEffect(layer({ experimentalBackgroundSubagents: true })) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 8d3431bac76..0451281a728 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -1,12 +1,13 @@ import { describe, expect } from "bun:test" -import { Cause, Effect, Exit, Layer, Schema } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Effect, Exit, Schema } from "effect" import { Agent } from "../../src/agent/agent" import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, Agent.node]))) const params = Schema.Struct({ input: Schema.String }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 6e65b5f54ca..d575a58ffa3 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,8 +1,9 @@ import { describe, test, expect } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { filesystem } from "@opencode-ai/core/effect/app-node-platform" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Effect, FileSystem, Layer } from "effect" +import { Effect, FileSystem } from "effect" import { Truncate } from "@/tool/truncate" import { Config } from "@/config/config" import { Identifier } from "../../src/id/id" @@ -15,15 +16,12 @@ import { TestConfig } from "../fixture/config" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") -const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem]))) const configuredLayer = (cfg: ConfigV1.Info) => - Layer.mergeAll( - Truncate.defaultLayer, - NodeFileSystem.layer, - FSUtil.defaultLayer, - TestConfig.layer({ get: () => Effect.succeed(cfg) }), - ) + LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem, Config.node]), [ + [Config.node, TestConfig.layer({ get: () => Effect.succeed(cfg) })], + ]) const configuredIt = (cfg: ConfigV1.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index fdf5210b9c1..05599a6784e 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" import { WebFetchTool } from "../../src/tool/webfetch" @@ -8,7 +10,11 @@ import { SessionID, MessageID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)) +const it = testEffect( + LayerNode.compile(LayerNode.group([httpClient, Truncate.node, Agent.node]), [ + [httpClient, FetchHttpClient.layer as Layer.Layer], + ]), +) const ctx = { sessionID: SessionID.make("ses_test"), diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 63a2a52aa98..4f897dc8759 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import path from "path" import fs from "fs/promises" @@ -31,14 +32,16 @@ afterEach(async () => { }) const it = testEffect( - Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - EventV2Bridge.defaultLayer, - Format.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, + LayerNode.compile( + LayerNode.group([ + LSP.node, + FSUtil.node, + EventV2Bridge.node, + Format.node, + CrossSpawnSpawner.node, + Truncate.node, + Agent.node, + ]), ), ) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index dabbfd802b2..f83b7a84f79 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -11,7 +11,11 @@ "exports": { ".": "./src/index.ts", "./tool": "./src/tool.ts", - "./tui": "./src/tui.ts" + "./tui": "./src/tui.ts", + "./v2/effect": "./src/v2/effect/index.ts", + "./v2/effect/integration": "./src/v2/effect/integration.ts", + "./v2/effect/plugin": "./src/v2/effect/plugin.ts", + "./v2/promise": "./src/v2/promise/index.ts" }, "files": [ "dist" @@ -19,7 +23,8 @@ "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", - "zod": "catalog:" + "zod": "catalog:", + "@ai-sdk/provider": "3.0.8" }, "peerDependencies": { "@opentui/core": ">=0.3.4", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 5af4d6ee603..beb59238d9b 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -376,6 +376,7 @@ export type TuiKV = { export type TuiState = { readonly ready: boolean readonly config: SdkConfig + readonly globalConfig: SdkConfig readonly provider: ReadonlyArray readonly path: { state: string @@ -383,7 +384,7 @@ export type TuiState = { worktree: string directory: string } - readonly vcs: { branch?: string } | undefined + readonly vcs: { branch?: string; default_branch?: string } | undefined session: { count: () => number get: (sessionID: string) => Session | undefined diff --git a/packages/plugin/src/v2/effect/PLAN.md b/packages/plugin/src/v2/effect/PLAN.md new file mode 100644 index 00000000000..e454257848d --- /dev/null +++ b/packages/plugin/src/v2/effect/PLAN.md @@ -0,0 +1,515 @@ +# V2 Plugin System Implementation Plan + +## Status + +This document describes the agreed target design for the V2 plugin system. It is an implementation plan, not documentation for the current API. + +## Goals + +- Internal and external plugins use the same public plugin API. +- Effect plugins import `@kilocode/plugin/v2/effect`, not `@opencode-ai/core`. +- Public domain values use generated `@kilocode/sdk` types. +- Core may retain branded IDs, decoded Effect schemas, and internal service types. +- Plugins may register replayable domain transforms and runtime hooks imperatively during setup. +- Registrations are scoped, independently disposable, ordered, and removable. +- Dynamic sources such as models.dev, config files, and skill directories can rebuild one domain without reloading the entire Location. +- The initial implementation covers the Effect API. A Promise API will be designed afterward as a wrapper over the same capabilities. + +## Authoring Model + +A plugin setup effect receives `PluginHost` and imperatively registers transforms and hooks. + +```ts +export const Plugin = define({ + id: "example", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) + }), + ) + + yield* ctx.tool.hook( + "execute.before", + Effect.fn(function* (event) { + event.args.update(sanitizeArgs) + }), + ) + }), +}) +``` + +Plugin setup does not return hooks. + +## Public Naming + +Settled names: + +- Replayable domain registration: `transform` +- Explicit domain replay: `rebuild` +- Runtime callback registration: `hook` +- Registration cleanup: `dispose` +- Event domain: singular `event` +- Other domains are singular: `agent`, `command`, `integration`, `reference`, `session`, `skill`, and `tool`; `catalog` remains `catalog` +- Hook names use dotted lifecycle names such as `"execute.before"` and `"execute.after"` + +## Transform API + +Each transformable domain exposes: + +```ts +interface TransformDomain { + transform(callback: (editor: Editor) => Effect.Effect): Effect.Effect + + rebuild(): Effect.Effect +} +``` + +The actual callback may be represented with the project's normal `Effect.fn` style. + +```ts +const registration = + yield * + ctx.catalog.transform( + Effect.fn(function* (catalog) { + const integration = yield* ctx.integration.get("anthropic") + if (!integration) return + + catalog.provider.update("anthropic", (provider) => { + provider.name = "Anthropic" + }) + }), + ) +``` + +Transforms may perform arbitrary Effects, including reads from other PluginHost services, filesystem I/O, and network I/O. Reads from another domain observe that domain's latest committed state. + +Transforms have no typed error channel. Unexpected failures are defects. + +## Transform Semantics + +- Every call to `transform()` creates an independent registration. +- Multiple transforms from one plugin and domain are allowed. +- Transform order is plugin registration order, then transform registration order within the plugin. +- A transform is automatically removed when its registration scope closes. +- `Registration.dispose` removes it early and is idempotent. +- Registering or disposing a transform automatically rebuilds its domain. +- During bulk plugin boot, automatic rebuilds are deferred and each affected domain is rebuilt once after the batch. +- `rebuild()` waits until replay and finalization complete. +- `rebuild()` always replays every active transform for the domain. +- Rebuilds are serialized and coalesced. Calls arriving during an active rebuild schedule at most one additional rebuild. +- A rebuild captures its registration list at the start. Concurrent registration changes affect the next rebuild. +- Transforms may not register or dispose transforms while replaying. Such changes are rejected or deferred by the runtime. +- Calling `rebuild()` for the currently rebuilding domain from one of its transforms is rejected. +- Rebuilding another domain from a transform is deferred until the current transform finishes. + +## Registration API + +Transforms and runtime hooks return the same Effect registration type. + +```ts +interface Registration { + readonly dispose: Effect.Effect +} +``` + +Registration behavior: + +- Automatically attached to the current `Scope.Scope` +- Explicitly disposable before scope closure +- Disposal affects future replays or invocations +- An in-flight rebuild or hook invocation uses the registration snapshot captured when it started and is allowed to finish + +## Runtime Hook API + +Domains expose runtime interception through `hook()`. + +```ts +const registration = + yield * + ctx.tool.hook( + "execute.before", + Effect.fn(function* (event) { + event.args.update(sanitizeArgs) + }), + ) +``` + +Runtime hook behavior: + +- Multiple registrations for the same hook are allowed. +- Hooks run sequentially in plugin and registration order. +- Later hooks observe mutations made by earlier hooks. +- Hook registration is scope-owned and independently disposable. +- Disposal affects future invocations; an in-flight invocation finishes using its captured registration snapshot. +- Runtime hooks are not replayed during domain rebuilds. +- Runtime hook callbacks have no typed error channel. + +## Hook Contexts + +Each hook receives one purpose-built context object rather than separate input/output parameters. + +```ts +ctx.tool.hook("execute.before", (event) => { + event.args.update((args) => ({ + ...args, + timeout: 30, + })) +}) +``` + +Hook context objects may contain: + +- Readonly SDK-typed operation data +- Purpose-built methods for allowed mutations +- Capability methods where the operation requires more than field assignment + +They must not expose core drafts or unrestricted internal objects. + +## Domain Transforms Versus Runtime Hooks + +Both use the same low-level scoped registration registry, but consumers invoke them differently. + +```ts +ctx.tool.transform(...) // replayed to build effective tool registry state +ctx.tool.hook(...) // invoked at a live tool operation boundary +``` + +The shared low-level machinery owns registration order, scope cleanup, disposal, and snapshots. Each domain owns when its transforms or runtime hooks execute. + +## Event API + +The Effect API exposes the existing event system as typed streams using generated SDK event discriminants. + +```ts +ctx.event.subscribe("catalog.updated") +// Stream.Stream +``` + +Example: + +```ts +yield * + ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => ctx.agent.rebuild()), + Effect.forkScoped, + ) +``` + +The plugin package derives event payload types from the generated SDK `Event` union: + +```ts +type EventMap = { + [Item in Event as Item["type"]]: Item +} +``` + +Core resolves the public event type string to its internal event definition and delegates to `EventV2.Service.subscribe`. + +## Domain State Model + +Each transformable core service continues to own: + +- Base state +- Effective committed state +- Editor creation +- Ordered transform registrations for that domain +- Rebuild serialization and coalescing +- Core finalization +- Commit and post-commit events + +The initial implementation should evolve the existing generic `State` helper rather than create a central cross-domain state manager. + +```text +base state +→ replay active transforms in order +→ core domain finalization +→ commit effective state +→ publish updated event +``` + +No cross-domain transform or transaction API is included. + +## Finalization + +Each domain has one plugin transform phase followed by core finalization. + +Core finalization is for invariants and materialization, not plugin extension behavior. + +Examples: + +- Catalog policy filtering and validation +- Reference repository materialization +- Integration connection projection +- Index construction +- Post-commit update events + +Finalizers should distinguish pre-commit work from post-commit notification. Update events should publish after the new state is visible. + +## Plugin Order + +The default distribution uses an opinionated internal order: + +```text +1. Built-in agents, commands, and skills +2. Base data sources such as models.dev +3. Configuration projections +4. Provider-specific normalization and authentication +5. External user plugins +6. Core domain finalization +``` + +For catalog transforms: + +```text +models.dev +→ config provider overrides +→ built-in provider normalization +→ user catalog transforms +→ catalog finalization +``` + +This replaces the current distinction between setup-installed State transforms and catalog hooks invoked from the catalog finalizer. + +Replacing a plugin with the same ID retains its existing order position. The old plugin is disabled before the replacement setup starts. + +## Boot Batching + +Plugin boot runs in an internal registration batch. + +```text +begin batch +→ initialize plugins sequentially +→ register transforms and hooks +→ collect affected domains +→ rebuild each affected domain once +→ end batch +``` + +Registration itself is not staged per plugin. If setup fails, closing the plugin's child scope removes every registration made before the failure. + +Outside a batch, transform registration and disposal rebuild immediately. + +## Models.dev Example + +Models.dev performs effectful reads directly from its transforms and rebuilds affected domains after refresh. + +```ts +export const ModelsDevPlugin = define({ + id: "models-dev", + effect: (ctx) => + Effect.gen(function* () { + const modelsDev = yield* ModelsDev.Service + const event = yield* EventV2.Service + + yield* ctx.integration.transform( + Effect.fn(function* (integration) { + const data = yield* modelsDev.get() + applyIntegrations(data, integration) + }), + ) + + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const data = yield* modelsDev.get() + applyCatalog(data, catalog) + }), + ) + + yield* event.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.runForEach( + Effect.fn(function* () { + yield* ctx.integration.rebuild() + yield* ctx.catalog.rebuild() + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) +``` + +The two domains rebuild sequentially. This plan does not add a cross-domain atomic transaction. + +## Config Watcher Example + +```ts +export const ConfigPlugin = define({ + id: "config", + effect: (ctx) => + Effect.gen(function* () { + const config = yield* ConfigSource.Service + + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + applyAgentConfig(yield* config.get(), agent) + }), + ) + + yield* ctx.command.transform( + Effect.fn(function* (command) { + applyCommandConfig(yield* config.get(), command) + }), + ) + + yield* config.changes.pipe( + Stream.runForEach( + Effect.fn(function* () { + yield* ctx.agent.rebuild() + yield* ctx.command.rebuild() + }), + ), + Effect.forkScoped, + ) + }), +}) +``` + +## Cross-Domain Read Example + +A transform may read another committed service. It must still arrange for its own domain to rebuild when that dependency changes. + +```ts +export const AnthropicAgentPlugin = define({ + id: "anthropic-agent", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + const providers = yield* ctx.catalog.provider.list() + if (!providers.some((provider) => provider.id === "anthropic")) return + + agent.update("anthropic-reviewer", (item) => { + item.description = "Reviews code using Anthropic" + item.mode = "subagent" + item.model = { + providerID: "anthropic", + id: "claude-sonnet", + } + }) + }), + ) + + yield* ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => ctx.agent.rebuild()), + Effect.forkScoped, + ) + }), +}) +``` + +The runtime does not infer cross-domain dependencies. + +## Embedding API Compatibility + +The imperative registration model maps naturally to a future application embedding API: + +```ts +const registration = oc.agent.transform((agent) => { + agent.update("reviewer", configureReviewer) +}) + +registration.dispose() +``` + +An application registration is stored as an application-level plugin registration. It attaches to every current Location and is installed during future Location boot. Disposal removes all current attachments and prevents future attachment. + +The Effect implementation remains the canonical runtime. Promise and embedding wrappers are deferred until after the Effect API is stable. + +## Migration Plan + +### 1. Define Public Contracts + +- Define `PluginHost` domain capabilities in `@kilocode/plugin/v2/effect`. +- Define SDK-typed editors for agent, catalog, command, integration, reference, skill, and tool. +- Define typed runtime hook maps per domain. +- Define `Registration`. +- Define typed `event.subscribe(type)`. + +### 2. Generalize Registration Machinery + +- Add one low-level scoped registration registry used by transforms and runtime hooks. +- Preserve plugin order and registration order. +- Support idempotent disposal and registration snapshots. +- Retain plugin position during same-ID replacement. + +### 3. Evolve State + +- Replace the current returned transform-slot updater with direct `transform(callback)` registration. +- Support Effectful callbacks. +- Add public `rebuild()`. +- Add rebuild serialization and coalescing. +- Add boot batching that defers automatic rebuilds. +- Move update event publication after commit. + +### 4. Expand Domain Transform Hooks + +- Agent +- Catalog +- Command +- Integration +- Reference +- Skill +- Tool + +### 5. Migrate Existing Plugins + +- Built-in agent transform +- Built-in command transform +- Built-in skill transform +- Models.dev catalog and integration transforms +- Config transforms +- OpenAI integration transform +- Provider catalog transforms + +### 6. Migrate Runtime Hooks + +- AI SDK resolution +- Language model resolution +- Tool execution hooks +- Session prompt/context hooks as required + +### 7. Remove Returned Hooks + +- Remove `HookFunctions` as the plugin setup return value. +- Remove catalog's special finalizer-triggered plugin hook path. +- Remove `plugin.added` catalog mutation handling. +- Make add/remove/replacement rely on scoped registration and domain rebuilds. + +### 8. Add Event Adapter + +- Build the SDK event discriminant map. +- Resolve public type strings to internal EventV2 definitions. +- Return typed Effect streams. + +### 9. Verification + +- Transform order is deterministic. +- Multiple transforms per plugin/domain compose. +- Registration and disposal rebuild automatically outside boot batches. +- Boot performs one rebuild per affected domain. +- Plugin setup failure removes prior registrations. +- Same-ID replacement retains order and disables the old plugin first. +- Rebuilds serialize and coalesce. +- Registration changes during replay affect the next rebuild. +- Same-domain recursive rebuild is rejected. +- Cross-domain rebuild requests from transforms are deferred. +- Hook execution is sequential and snapshot-based. +- Models.dev refresh replays config and provider transforms. +- Config and skill watcher refreshes remove stale entries. +- Plugin removal restores prior effective state. +- Events observe newly committed state. + +## Deferred Decisions + +- Promise API shape +- Typed error model +- Transform timeouts +- Cross-domain atomic rebuilds +- Automatic dependency tracking +- Whole-Location generation reload +- Exact editors and runtime hooks not required by current plugins diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md new file mode 100644 index 00000000000..2f8a9d143ef --- /dev/null +++ b/packages/plugin/src/v2/effect/README.md @@ -0,0 +1,111 @@ +# OpenCode V2 Effect Plugin API + +The Effect plugin API grants plugins two in-process capabilities: + +- `hook` installs behavior at an OpenCode extension point. +- `reload` reruns every transform hook for a stateful domain. + +The public server client will be exposed separately. It is intentionally not part of `PluginContext` yet. + +## Defining A Plugin + +```ts +import { define } from "@kilocode/plugin/v2/effect" +import { Effect } from "effect" + +export const Plugin = define({ + id: "example", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform((catalog) => { + catalog.provider.update("example", (provider) => { + provider.name = "Example" + }) + }) + }), +}) +``` + +Plugin setup registers hooks imperatively. It does not return a hook object. + +Configuration supplied for the plugin is available as `ctx.options`. + +Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`. + +## Transform Hooks + +Transform hooks contribute to stateful domains: + +```ts +yield * + ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) + }) +``` + +OpenCode rebuilds the domain when a transform is registered or disposed. A rebuild starts from fresh domain state and runs every active transform in registration order. + +Available transform hooks are namespaced by domain: + +```ts +ctx.agent.transform +ctx.catalog.transform +ctx.command.transform +ctx.integration.transform +ctx.reference.transform +ctx.skill.transform +``` + +## Runtime Hooks + +Runtime hooks intercept live operations rather than rebuilding domain state: + +```ts +yield * + ctx.aisdk.sdk( + Effect.fn(function* (event) { + if (event.package !== "@ai-sdk/xai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) + event.sdk = mod.createXai(event.options) + }), + ) + +yield * + ctx.aisdk.language((event) => { + if (event.model.providerID !== "xai") return + event.language = event.sdk.responses(event.model.api.id) + }) +``` + +Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. + +## Reloading A Domain + +When data captured by a transform changes, reload the affected domain: + +```ts +let data = yield * loadCatalog() + +yield * + ctx.catalog.transform((catalog) => { + applyCatalog(data, catalog) + }) + +data = yield * loadCatalog() +yield * ctx.catalog.reload() +``` + +Reload belongs to the domain, not an individual registration. `ctx.catalog.reload()` reruns every active catalog transform and publishes the rebuilt catalog. + +Available reload operations are: + +```ts +ctx.agent.reload() +ctx.catalog.reload() +ctx.command.reload() +ctx.integration.reload() +ctx.reference.reload() +ctx.skill.reload() +``` diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts new file mode 100644 index 00000000000..9763f4dafa1 --- /dev/null +++ b/packages/plugin/src/v2/effect/agent.ts @@ -0,0 +1,14 @@ +import type { AgentV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface AgentDraft { + list(): readonly AgentV2Info[] + get(id: string): AgentV2Info | undefined + default(id: string | undefined): void + update(id: string, update: (agent: AgentV2Info) => void): void + remove(id: string): void +} + +export type AgentHooks = Hooks<{ + transform: AgentDraft +}> diff --git a/packages/plugin/src/v2/effect/aisdk.ts b/packages/plugin/src/v2/effect/aisdk.ts new file mode 100644 index 00000000000..fbc024892d1 --- /dev/null +++ b/packages/plugin/src/v2/effect/aisdk.ts @@ -0,0 +1,18 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export type AISDKHooks = Hooks<{ + sdk: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any + } + language: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 + } +}> diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts new file mode 100644 index 00000000000..bba86173ee4 --- /dev/null +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -0,0 +1,29 @@ +import type { ModelV2Info, ProviderV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface CatalogProviderRecord { + readonly provider: ProviderV2Info + readonly models: ReadonlyMap +} + +export interface CatalogDraft { + readonly provider: { + list(): readonly CatalogProviderRecord[] + get(providerID: string): CatalogProviderRecord | undefined + update(providerID: string, update: (provider: ProviderV2Info) => void): void + remove(providerID: string): void + } + readonly model: { + get(providerID: string, modelID: string): ModelV2Info | undefined + update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void + remove(providerID: string, modelID: string): void + readonly default: { + get(): { providerID: string; modelID: string } | undefined + set(providerID: string, modelID: string): void + } + } +} + +export type CatalogHooks = Hooks<{ + transform: CatalogDraft +}> diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts new file mode 100644 index 00000000000..c78188d0b88 --- /dev/null +++ b/packages/plugin/src/v2/effect/command.ts @@ -0,0 +1,13 @@ +import type { CommandV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface CommandDraft { + list(): readonly CommandV2Info[] + get(name: string): CommandV2Info | undefined + update(name: string, update: (command: CommandV2Info) => void): void + remove(name: string): void +} + +export type CommandHooks = Hooks<{ + transform: CommandDraft +}> diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts new file mode 100644 index 00000000000..9089334ee3b --- /dev/null +++ b/packages/plugin/src/v2/effect/context.ts @@ -0,0 +1,22 @@ +import type { PluginOptions } from "../options.js" +import type { AgentHooks } from "./agent.js" +import type { AISDKHooks } from "./aisdk.js" +import type { CatalogHooks } from "./catalog.js" +import type { CommandHooks } from "./command.js" +import type { IntegrationHooks } from "./integration.js" +import type { PluginDomain } from "./plugin.js" +import type { ReferenceHooks } from "./reference.js" +import type { SkillHooks } from "./skill.js" +import type { Reload } from "./registration.js" + +export interface PluginContext { + readonly options: PluginOptions + readonly agent: AgentHooks & Reload + readonly aisdk: AISDKHooks + readonly catalog: CatalogHooks & Reload + readonly command: CommandHooks & Reload + readonly integration: IntegrationHooks & Reload + readonly plugin: PluginDomain + readonly reference: ReferenceHooks & Reload + readonly skill: SkillHooks & Reload +} diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts new file mode 100644 index 00000000000..92ca78c28e4 --- /dev/null +++ b/packages/plugin/src/v2/effect/event.ts @@ -0,0 +1,10 @@ +import type { Event as SDKEvent } from "@kilocode/sdk/v2/types" +import type { Stream } from "effect" + +export type EventMap = { + [Item in SDKEvent as Item["type"]]: Item +} + +export interface Event { + subscribe(type: Type): Stream.Stream +} diff --git a/packages/plugin/src/v2/effect/filesystem.ts b/packages/plugin/src/v2/effect/filesystem.ts new file mode 100644 index 00000000000..57e06614488 --- /dev/null +++ b/packages/plugin/src/v2/effect/filesystem.ts @@ -0,0 +1,17 @@ +import type { FileSystemEntry } from "@kilocode/sdk/v2/types" +import type { Effect } from "effect" + +export interface FileSystem { + read(input: { readonly path: string }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> + list(input?: { readonly path?: string }): Effect.Effect + find(input: { + readonly query: string + readonly type?: "file" | "directory" + readonly limit?: number + }): Effect.Effect + glob(input: { + readonly pattern: string + readonly path?: string + readonly limit?: number + }): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts new file mode 100644 index 00000000000..f13614a54da --- /dev/null +++ b/packages/plugin/src/v2/effect/index.ts @@ -0,0 +1,3 @@ +export type { PluginContext } from "./context.js" +export { define } from "./plugin.js" +export type { Plugin } from "./plugin.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts new file mode 100644 index 00000000000..6eb778d45a0 --- /dev/null +++ b/packages/plugin/src/v2/effect/integration.ts @@ -0,0 +1,63 @@ +import type { + ConnectionInfo, + CredentialOAuth, + CredentialValue, + IntegrationEnvMethod, + IntegrationInputs, + IntegrationKeyMethod, + IntegrationMethod, + IntegrationOAuthMethod, + IntegrationRef, +} from "@kilocode/sdk/v2/types" +import type { Effect, Scope } from "effect" +import type { Hooks } from "./registration.js" + +export type IntegrationOAuthAuthorization = { + readonly url: string + readonly instructions: string +} & ( + | { + readonly mode: "auto" + readonly callback: Effect.Effect + } + | { + readonly mode: "code" + readonly callback: (code: string) => Effect.Effect + } +) +export type IntegrationOAuthMethodRegistration = { + readonly integrationID: string + readonly method: IntegrationOAuthMethod + readonly authorize: (inputs: IntegrationInputs) => Effect.Effect + readonly refresh?: (credential: CredentialOAuth) => Effect.Effect + readonly label?: (credential: CredentialOAuth) => string | undefined +} +export type IntegrationMethodRegistration = + | IntegrationOAuthMethodRegistration + | { + readonly integrationID: string + readonly method: IntegrationKeyMethod + } + | { + readonly integrationID: string + readonly method: IntegrationEnvMethod + } + +export interface IntegrationDraft { + list(): readonly IntegrationRef[] + get(id: string): IntegrationRef | undefined + update(id: string, update: (integration: IntegrationRef) => void): void + remove(id: string): void + readonly method: { + list(integrationID: string): readonly IntegrationMethod[] + update(input: IntegrationMethodRegistration): void + remove(integrationID: string, method: IntegrationMethod): void + } +} + +export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> { + readonly connection: { + readonly active: (integrationID: string) => Effect.Effect + readonly resolve: (connection: ConnectionInfo) => Effect.Effect + } +} diff --git a/packages/plugin/src/v2/effect/location.ts b/packages/plugin/src/v2/effect/location.ts new file mode 100644 index 00000000000..bc546a3b175 --- /dev/null +++ b/packages/plugin/src/v2/effect/location.ts @@ -0,0 +1,6 @@ +export interface Location { + readonly directory: string + readonly project: { + readonly directory: string + } +} diff --git a/packages/plugin/src/v2/effect/npm.ts b/packages/plugin/src/v2/effect/npm.ts new file mode 100644 index 00000000000..4cb96c32d17 --- /dev/null +++ b/packages/plugin/src/v2/effect/npm.ts @@ -0,0 +1,11 @@ +import type { Effect } from "effect" + +export interface Npm { + add(pkg: string): Effect.Effect< + { + readonly directory: string + readonly entrypoint?: string + }, + unknown + > +} diff --git a/packages/plugin/src/v2/effect/path.ts b/packages/plugin/src/v2/effect/path.ts new file mode 100644 index 00000000000..f9045cc32d0 --- /dev/null +++ b/packages/plugin/src/v2/effect/path.ts @@ -0,0 +1,8 @@ +export interface Path { + readonly home: string + readonly data: string + readonly cache: string + readonly config: string + readonly state: string + readonly temp: string +} diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts new file mode 100644 index 00000000000..54224fa8318 --- /dev/null +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -0,0 +1,16 @@ +import type { Effect, Scope } from "effect" +import type { PluginContext } from "./context.js" + +export interface Plugin { + readonly id: string + readonly effect: (context: PluginContext) => Effect.Effect +} + +export function define(plugin: Plugin) { + return plugin +} + +export interface PluginDomain { + readonly add: (plugin: Plugin) => Effect.Effect + readonly remove: (id: string) => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts new file mode 100644 index 00000000000..08aa350e5bb --- /dev/null +++ b/packages/plugin/src/v2/effect/reference.ts @@ -0,0 +1,12 @@ +import type { ReferenceGitSource, ReferenceLocalSource } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface ReferenceDraft { + add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void + remove(name: string): void + list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] +} + +export type ReferenceHooks = Hooks<{ + transform: ReferenceDraft +}> diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts new file mode 100644 index 00000000000..dfe56263976 --- /dev/null +++ b/packages/plugin/src/v2/effect/registration.ts @@ -0,0 +1,15 @@ +import type { Effect, Scope } from "effect" + +export interface Registration { + readonly dispose: Effect.Effect +} + +export interface Reload { + readonly reload: () => Effect.Effect +} + +export type Hooks = { + readonly [Name in keyof Spec]: ( + callback: (input: Spec[Name]) => Effect.Effect | void, + ) => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts new file mode 100644 index 00000000000..3b039a893b8 --- /dev/null +++ b/packages/plugin/src/v2/effect/skill.ts @@ -0,0 +1,11 @@ +import type { SkillV2Source } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export interface SkillDraft { + source(source: SkillV2Source): void + list(): readonly SkillV2Source[] +} + +export type SkillHooks = Hooks<{ + transform: SkillDraft +}> diff --git a/packages/plugin/src/v2/options.ts b/packages/plugin/src/v2/options.ts new file mode 100644 index 00000000000..2b210f943b2 --- /dev/null +++ b/packages/plugin/src/v2/options.ts @@ -0,0 +1 @@ +export type PluginOptions = Readonly> diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md new file mode 100644 index 00000000000..3f509eaef68 --- /dev/null +++ b/packages/plugin/src/v2/promise/README.md @@ -0,0 +1,103 @@ +# OpenCode V2 Promise Plugin API + +The Promise plugin API is the async/await equivalent of `@kilocode/plugin/v2/effect`. It grants plugins the same two in-process capabilities: + +- `hook` installs behavior at an OpenCode extension point. +- `reload` reruns every transform hook for a stateful domain. + +The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects. + +## Defining A Plugin + +```ts +import { define } from "@kilocode/plugin/v2/promise" + +export const Plugin = define({ + id: "example", + setup: async (ctx) => { + await ctx.catalog.transform((catalog) => { + catalog.provider.update("example", (provider) => { + provider.name = "Example" + }) + }) + }, +}) +``` + +Plugin setup registers hooks imperatively. It does not return a hook object. + +Configuration supplied for the plugin is available as `ctx.options`. + +A registration may be removed early through `dispose`: + +```ts +const registration = await ctx.catalog.transform(applyCatalog) +await registration.dispose() +``` + +## Transform Hooks + +Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work: + +```ts +await ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) +}) +``` + +Available transform hooks are namespaced by domain: + +```ts +ctx.agent.transform +ctx.catalog.transform +ctx.command.transform +ctx.integration.transform +ctx.reference.transform +ctx.skill.transform +``` + +## Runtime Hooks + +Runtime hooks intercept live operations: + +```ts +await ctx.aisdk.sdk(async (event) => { + if (event.package !== "@ai-sdk/xai") return + const mod = await import("@ai-sdk/xai") + event.sdk = mod.createXai(event.options) +}) + +await ctx.aisdk.language((event) => { + if (event.model.providerID !== "xai") return + event.language = event.sdk.responses(event.model.api.id) +}) +``` + +## Reloading A Domain + +When data captured by a transform changes, reload the affected domain: + +```ts +let data = await loadCatalog() + +await ctx.catalog.transform((catalog) => { + applyCatalog(data, catalog) +}) + +data = await loadCatalog() +await ctx.catalog.reload() +``` + +Available reload operations are: + +```ts +ctx.agent.reload() +ctx.catalog.reload() +ctx.command.reload() +ctx.integration.reload() +ctx.reference.reload() +ctx.skill.reload() +``` diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts new file mode 100644 index 00000000000..bec589146ae --- /dev/null +++ b/packages/plugin/src/v2/promise/agent.ts @@ -0,0 +1,8 @@ +import type { AgentDraft } from "../effect/agent.js" +import type { Hooks } from "./registration.js" + +export type { AgentDraft } + +export type AgentHooks = Hooks<{ + transform: AgentDraft +}> diff --git a/packages/plugin/src/v2/promise/aisdk.ts b/packages/plugin/src/v2/promise/aisdk.ts new file mode 100644 index 00000000000..fbc024892d1 --- /dev/null +++ b/packages/plugin/src/v2/promise/aisdk.ts @@ -0,0 +1,18 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export type AISDKHooks = Hooks<{ + sdk: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any + } + language: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 + } +}> diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts new file mode 100644 index 00000000000..70842e94b71 --- /dev/null +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -0,0 +1,8 @@ +import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js" +import type { Hooks } from "./registration.js" + +export type { CatalogDraft, CatalogProviderRecord } + +export type CatalogHooks = Hooks<{ + transform: CatalogDraft +}> diff --git a/packages/plugin/src/v2/promise/command.ts b/packages/plugin/src/v2/promise/command.ts new file mode 100644 index 00000000000..cdc5f8268a1 --- /dev/null +++ b/packages/plugin/src/v2/promise/command.ts @@ -0,0 +1,8 @@ +import type { CommandDraft } from "../effect/command.js" +import type { Hooks } from "./registration.js" + +export type { CommandDraft } + +export type CommandHooks = Hooks<{ + transform: CommandDraft +}> diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts new file mode 100644 index 00000000000..9089334ee3b --- /dev/null +++ b/packages/plugin/src/v2/promise/context.ts @@ -0,0 +1,22 @@ +import type { PluginOptions } from "../options.js" +import type { AgentHooks } from "./agent.js" +import type { AISDKHooks } from "./aisdk.js" +import type { CatalogHooks } from "./catalog.js" +import type { CommandHooks } from "./command.js" +import type { IntegrationHooks } from "./integration.js" +import type { PluginDomain } from "./plugin.js" +import type { ReferenceHooks } from "./reference.js" +import type { SkillHooks } from "./skill.js" +import type { Reload } from "./registration.js" + +export interface PluginContext { + readonly options: PluginOptions + readonly agent: AgentHooks & Reload + readonly aisdk: AISDKHooks + readonly catalog: CatalogHooks & Reload + readonly command: CommandHooks & Reload + readonly integration: IntegrationHooks & Reload + readonly plugin: PluginDomain + readonly reference: ReferenceHooks & Reload + readonly skill: SkillHooks & Reload +} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts new file mode 100644 index 00000000000..82879556573 --- /dev/null +++ b/packages/plugin/src/v2/promise/index.ts @@ -0,0 +1,12 @@ +export type { PluginContext } from "./context.js" +export type { PluginOptions } from "../options.js" +export { define } from "./plugin.js" +export type { Plugin, PluginDomain } from "./plugin.js" +export type { Registration, Reload } from "./registration.js" +export type { AgentDraft, AgentHooks } from "./agent.js" +export type { AISDKHooks } from "./aisdk.js" +export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" +export type { CommandDraft, CommandHooks } from "./command.js" +export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" +export type { ReferenceDraft, ReferenceHooks } from "./reference.js" +export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts new file mode 100644 index 00000000000..7f516655c48 --- /dev/null +++ b/packages/plugin/src/v2/promise/integration.ts @@ -0,0 +1,14 @@ +import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" +import type { CredentialValue } from "@kilocode/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export type { IntegrationDraft, IntegrationMethodRegistration } + +export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> { + readonly connection: { + readonly active: (integrationID: string) => Promise + readonly resolve: ( + connection: import("@kilocode/sdk/v2/types").ConnectionInfo, + ) => Promise + } +} diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts new file mode 100644 index 00000000000..ab59fb95fc9 --- /dev/null +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -0,0 +1,15 @@ +import type { PluginContext } from "./context.js" + +export interface Plugin { + readonly id: string + readonly setup: (context: PluginContext) => Promise | void +} + +export function define(plugin: Plugin) { + return plugin +} + +export interface PluginDomain { + readonly add: (plugin: Plugin) => Promise + readonly remove: (id: string) => Promise +} diff --git a/packages/plugin/src/v2/promise/reference.ts b/packages/plugin/src/v2/promise/reference.ts new file mode 100644 index 00000000000..f4b7f8b8393 --- /dev/null +++ b/packages/plugin/src/v2/promise/reference.ts @@ -0,0 +1,8 @@ +import type { ReferenceDraft } from "../effect/reference.js" +import type { Hooks } from "./registration.js" + +export type { ReferenceDraft } + +export type ReferenceHooks = Hooks<{ + transform: ReferenceDraft +}> diff --git a/packages/plugin/src/v2/promise/registration.ts b/packages/plugin/src/v2/promise/registration.ts new file mode 100644 index 00000000000..5e0ae7f480a --- /dev/null +++ b/packages/plugin/src/v2/promise/registration.ts @@ -0,0 +1,11 @@ +export interface Registration { + readonly dispose: () => Promise +} + +export interface Reload { + readonly reload: () => Promise +} + +export type Hooks = { + readonly [Name in keyof Spec]: (callback: (input: Spec[Name]) => Promise | void) => Promise +} diff --git a/packages/plugin/src/v2/promise/skill.ts b/packages/plugin/src/v2/promise/skill.ts new file mode 100644 index 00000000000..2efc35f818a --- /dev/null +++ b/packages/plugin/src/v2/promise/skill.ts @@ -0,0 +1,8 @@ +import type { SkillDraft } from "../effect/skill.js" +import type { Hooks } from "./registration.js" + +export type { SkillDraft } + +export type SkillHooks = Hooks<{ + transform: SkillDraft +}> diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 00000000000..f538635b18e --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/protocol", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "version": "7.4.16" +} diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts new file mode 100644 index 00000000000..c7d52f855ef --- /dev/null +++ b/packages/protocol/src/api.ts @@ -0,0 +1,86 @@ +import { Context } from "effect" +import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" +import { SchemaErrorMiddleware } from "./middleware/schema-error" +import { MessageGroup } from "./groups/message" +import { ModelGroup } from "./groups/model" +import { ProviderGroup } from "./groups/provider" +import { makeSessionGroup } from "./groups/session" +import { makePermissionGroup } from "./groups/permission" +import { FileSystemGroup } from "./groups/fs" +import { CommandGroup } from "./groups/command" +import { SkillGroup } from "./groups/skill" +import { EventGroup, makeEventGroup } from "./groups/event" +import type { Definition } from "@opencode-ai/schema/event" +import { AgentGroup } from "./groups/agent" +import { HealthGroup } from "./groups/health" +import { PtyGroup } from "./groups/pty" +import { makeQuestionGroup } from "./groups/question" +import { ReferenceGroup } from "./groups/reference" +import { Authorization } from "./middleware/authorization" +import { LocationGroup } from "./groups/location" +import { IntegrationGroup } from "./groups/integration" +import { CredentialGroup } from "./groups/credential" +import { ProjectCopyGroup } from "./groups/project-copy" + +// Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream. +const makeApiFromGroup = < + const Group extends HttpApiGroup.Any, + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + eventGroup: Group, + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApi.make("server") + .add(HealthGroup) + .add(LocationGroup.middleware(locationMiddleware)) + .add(AgentGroup.middleware(locationMiddleware)) + .add(makeSessionGroup(sessionLocationMiddleware, locationMiddleware)) // kilocode_change - provide configured location to session creation + .add(MessageGroup.middleware(sessionLocationMiddleware)) + .add(ModelGroup.middleware(locationMiddleware)) + .add(ProviderGroup.middleware(locationMiddleware)) + .add(IntegrationGroup.middleware(locationMiddleware)) + .add(CredentialGroup.middleware(locationMiddleware)) + .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(FileSystemGroup.middleware(locationMiddleware)) + .add(CommandGroup.middleware(locationMiddleware)) + .add(SkillGroup.middleware(locationMiddleware)) + .add(eventGroup) + .add(PtyGroup.middleware(locationMiddleware)) + .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(ReferenceGroup.middleware(locationMiddleware)) + .add(ProjectCopyGroup.middleware(locationMiddleware)) + .annotateMerge( + OpenApi.annotations({ + title: "Kilo HttpApi", // kilocode_change - public API grouping is Kilo-branded + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + .middleware(Authorization) + .middleware(SchemaErrorMiddleware) + +export const makeApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly definitions: ReadonlyArray + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => + makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware) + +export const makeDefaultApi = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>(options: { + readonly locationMiddleware: Context.Key + readonly sessionLocationMiddleware: Context.Key +}) => makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts new file mode 100644 index 00000000000..3b1eced63a2 --- /dev/null +++ b/packages/protocol/src/errors.ts @@ -0,0 +1,111 @@ +import { Schema } from "effect" + +export class InvalidRequestError extends Schema.TaggedErrorClass()( + "InvalidRequestError", + { + message: Schema.String, + kind: Schema.optional(Schema.String), + field: Schema.optional(Schema.String), + }, + { httpApiStatus: 400 }, +) {} + +export class UnauthorizedError extends Schema.TaggedErrorClass()( + "UnauthorizedError", + { message: Schema.String }, + { httpApiStatus: 401 }, +) {} + +export class ConflictError extends Schema.TaggedErrorClass()( + "ConflictError", + { + message: Schema.String, + resource: Schema.optional(Schema.String), + }, + { httpApiStatus: 409 }, +) {} + +export class ServiceUnavailableError extends Schema.TaggedErrorClass()( + "ServiceUnavailableError", + { + message: Schema.String, + service: Schema.optional(Schema.String), + }, + { httpApiStatus: 503 }, +) {} + +export class UnknownError extends Schema.TaggedErrorClass()( + "UnknownError", + { + message: Schema.String, + ref: Schema.optional(Schema.String), + }, + { httpApiStatus: 500 }, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "ProviderNotFoundError", + { + providerID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class SessionNotFoundError extends Schema.TaggedErrorClass()( + "SessionNotFoundError", + { + sessionID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class MessageNotFoundError extends Schema.TaggedErrorClass()( + "MessageNotFoundError", + { + sessionID: Schema.String, + messageID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class InvalidCursorError extends Schema.TaggedErrorClass()( + "InvalidCursorError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +export class PermissionNotFoundError extends Schema.TaggedErrorClass()( + "PermissionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class QuestionNotFoundError extends Schema.TaggedErrorClass()( + "QuestionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class ForbiddenError extends Schema.TaggedErrorClass()( + "ForbiddenError", + { message: Schema.String }, + { httpApiStatus: 403 }, +) {} + +export class PtyNotFoundError extends Schema.TaggedErrorClass()( + "PtyNotFoundError", + { + ptyID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/protocol/src/groups/agent.ts b/packages/protocol/src/groups/agent.ts new file mode 100644 index 00000000000..0d499e187f9 --- /dev/null +++ b/packages/protocol/src/groups/agent.ts @@ -0,0 +1,20 @@ +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const AgentGroup = HttpApiGroup.make("server.agent").add( + HttpApiEndpoint.get("agent.list", "/api/agent", { + query: LocationQuery, + success: Location.response(Schema.Array(Agent.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.agent.list", + summary: "List agents", + description: "Retrieve currently registered agents.", + }), + ), +) diff --git a/packages/server/src/groups/command.ts b/packages/protocol/src/groups/command.ts similarity index 69% rename from packages/server/src/groups/command.ts rename to packages/protocol/src/groups/command.ts index 581dd19e030..eac33cc2927 100644 --- a/packages/server/src/groups/command.ts +++ b/packages/protocol/src/groups/command.ts @@ -1,14 +1,14 @@ -import { CommandV2 } from "@opencode-ai/core/command" -import { Location } from "@opencode-ai/core/location" +import { Command } from "@opencode-ai/schema/command" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const CommandGroup = HttpApiGroup.make("server.command") .add( HttpApiEndpoint.get("command.list", "/api/command", { query: LocationQuery, - success: Location.response(Schema.Array(CommandV2.Info)), + success: Location.response(Schema.Array(Command.Info)), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( @@ -25,4 +25,3 @@ export const CommandGroup = HttpApiGroup.make("server.command") description: "Experimental command routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts similarity index 87% rename from packages/server/src/groups/credential.ts rename to packages/protocol/src/groups/credential.ts index b6e21ca30b7..4f6ce8461bb 100644 --- a/packages/server/src/groups/credential.ts +++ b/packages/protocol/src/groups/credential.ts @@ -1,7 +1,7 @@ -import { Credential } from "@opencode-ai/core/credential" +import { Credential } from "@opencode-ai/schema/credential" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const CredentialGroup = HttpApiGroup.make("server.credential") .add( @@ -35,4 +35,3 @@ export const CredentialGroup = HttpApiGroup.make("server.credential") }), ), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/event.ts b/packages/protocol/src/groups/event.ts new file mode 100644 index 00000000000..ab4ec63b9f8 --- /dev/null +++ b/packages/protocol/src/groups/event.ts @@ -0,0 +1,58 @@ +import { Event } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { Location } from "@opencode-ai/schema/location" +import type { Definition } from "@opencode-ai/schema/event" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" + +const fields = { + id: Event.ID, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), + location: Schema.optional(Location.Ref), +} + +const schema = >(definitions: Definitions) => + Schema.Union([ + ...definitions, + ...(definitions.some((definition) => definition.type === "server.connected") + ? [] + : [ + Schema.Struct({ + ...fields, + type: Schema.Literal("server.connected"), + data: Schema.Struct({}), + }).annotate({ identifier: "V2Event.server.connected" }), + ]), + ]).annotate({ identifier: "V2Event" }) + +const make = >(definitions: Definitions) => { + const EventSchema = schema(definitions) + return { + schema: EventSchema, + group: HttpApiGroup.make("server.event") + .add( + HttpApiEndpoint.get("event.subscribe", "/api/event", { + success: HttpApiSchema.StreamSse({ data: EventSchema }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.event.subscribe", + summary: "Subscribe to events", + description: "Subscribe to native event payloads for the server.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })), + } +} + +export const makeEventGroup = >(definitions: Definitions) => + make(definitions).group + +const event = make(EventManifest.ServerDefinitions) +export const EventGroup = event.group +export const OpenCodeEvent = event.schema +export type OpenCodeEvent = typeof OpenCodeEvent.Type +export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded + +export const KiloEvent = schema(EventManifest.Definitions) // kilocode_change - encode the full Kilo event bus diff --git a/packages/server/src/groups/fs.ts b/packages/protocol/src/groups/fs.ts similarity index 83% rename from packages/server/src/groups/fs.ts rename to packages/protocol/src/groups/fs.ts index d72ff216f00..62bdf140589 100644 --- a/packages/server/src/groups/fs.ts +++ b/packages/protocol/src/groups/fs.ts @@ -1,15 +1,22 @@ -import { FileSystem } from "@opencode-ai/core/filesystem" -import { Location } from "@opencode-ai/core/location" -import { PositiveInt, RelativePath } from "@opencode-ai/core/schema" +import { FileSystem } from "@opencode-ai/schema/filesystem" +import { Location } from "@opencode-ai/schema/location" +import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" const ListQuery = Schema.Struct({ ...LocationQuery.fields, path: RelativePath.pipe(Schema.optional), }) +// kilocode_change start - expose the wildcard resource to generated SDK callers +const ReadQuery = Schema.Struct({ + ...LocationQuery.fields, + path: RelativePath.pipe(Schema.optional), +}) +// kilocode_change end + const FindQuery = Schema.Struct({ ...LocationQuery.fields, query: FileSystem.FindInput.fields.query, @@ -17,11 +24,6 @@ const FindQuery = Schema.Struct({ limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional), }) -const ReadQuery = Schema.Struct({ - ...LocationQuery.fields, - path: RelativePath.pipe(Schema.optional), // kilocode_change - expose the wildcard resource to generated SDK callers -}) - export const FileSystemGroup = HttpApiGroup.make("server.fs") .add( HttpApiEndpoint.get("fs.read", "/api/fs/read/*", { @@ -71,4 +73,3 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs") description: "Experimental location-scoped filesystem routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/health.ts b/packages/protocol/src/groups/health.ts similarity index 100% rename from packages/server/src/groups/health.ts rename to packages/protocol/src/groups/health.ts diff --git a/packages/server/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts similarity index 95% rename from packages/server/src/groups/integration.ts rename to packages/protocol/src/groups/integration.ts index 30e82ace6e1..304681d3305 100644 --- a/packages/server/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -1,9 +1,9 @@ -import { Integration } from "@opencode-ai/core/integration" -import { Location } from "@opencode-ai/core/location" +import { Integration } from "@opencode-ai/schema/integration" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { InvalidRequestError } from "../errors" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" const Inputs = Schema.Record(Schema.String, Schema.String) @@ -128,4 +128,3 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") .annotateMerge( OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/location.ts b/packages/protocol/src/groups/location.ts new file mode 100644 index 00000000000..1752bae9beb --- /dev/null +++ b/packages/protocol/src/groups/location.ts @@ -0,0 +1,42 @@ +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const LocationQuery = Schema.Struct({ + location: Schema.optional( + Schema.Struct({ + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + }), + ), +}).annotate({ identifier: "LocationQuery" }) + +export const locationQueryOpenApi = OpenApi.annotations({ + transform: (operation) => { + const parameters = operation.parameters + if (!Array.isArray(parameters)) return operation + return { + ...operation, + parameters: parameters.map((parameter) => + parameter?.name === "location" && parameter?.in === "query" + ? { ...parameter, style: "deepObject", explode: true } + : parameter, + ), + } + }, +}) + +export const LocationGroup = HttpApiGroup.make("server.location").add( + HttpApiEndpoint.get("location.get", "/api/location", { + query: LocationQuery, + success: Location.Info, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.location.get", + summary: "Get location", + description: "Resolve the requested location or the server default location.", + }), + ), +) diff --git a/packages/server/src/groups/message.ts b/packages/protocol/src/groups/message.ts similarity index 72% rename from packages/server/src/groups/message.ts rename to packages/protocol/src/groups/message.ts index b8a6e19ab64..7ace0ada994 100644 --- a/packages/server/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -1,9 +1,8 @@ -import { SessionV2 } from "@opencode-ai/core/session" -import { SessionMessage } from "@opencode-ai/core/session/message" +import { Session } from "@opencode-ai/schema/session" +import { SessionMessage } from "@opencode-ai/schema/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" export const SessionMessagesQuery = Schema.Struct({ limit: Schema.optional( @@ -25,7 +24,7 @@ export const SessionMessagesQuery = Schema.Struct({ export const MessageGroup = HttpApiGroup.make("server.message") .add( HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", { - params: { sessionID: SessionV2.ID }, + params: { sessionID: Session.ID }, query: SessionMessagesQuery, success: Schema.Struct({ data: Schema.Array(SessionMessage.Message), @@ -35,16 +34,14 @@ export const MessageGroup = HttpApiGroup.make("server.message") }), }).annotate({ identifier: "SessionMessagesResponse" }), error: [InvalidCursorError, SessionNotFoundError, UnknownError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.messages", - summary: "Get session messages", - description: - "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", - }), - ), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.messages", + summary: "Get session messages", + description: + "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/server/src/groups/model.ts b/packages/protocol/src/groups/model.ts similarity index 72% rename from packages/server/src/groups/model.ts rename to packages/protocol/src/groups/model.ts index 3964ae4789e..9125f952892 100644 --- a/packages/server/src/groups/model.ts +++ b/packages/protocol/src/groups/model.ts @@ -1,15 +1,15 @@ -import { ModelV2 } from "@opencode-ai/core/model" -import { Location } from "@opencode-ai/core/location" +import { Model } from "@opencode-ai/schema/model" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ServiceUnavailableError } from "../errors" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const ModelGroup = HttpApiGroup.make("server.model") .add( HttpApiEndpoint.get("model.list", "/api/model", { query: LocationQuery, - success: Location.response(Schema.Array(ModelV2.Info)), + success: Location.response(Schema.Array(Model.Info)), error: ServiceUnavailableError, }) .annotateMerge(locationQueryOpenApi) @@ -27,4 +27,3 @@ export const ModelGroup = HttpApiGroup.make("server.model") description: "Experimental model routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts new file mode 100644 index 00000000000..4370e18a71e --- /dev/null +++ b/packages/protocol/src/groups/permission.ts @@ -0,0 +1,137 @@ +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Permission } from "@opencode-ai/schema/permission" +import { PermissionSaved } from "@opencode-ai/schema/permission-saved" +import { Project } from "@opencode-ai/schema/project" +import { Session } from "@opencode-ai/schema/session" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { PermissionNotFoundError, SessionNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const makePermissionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApiGroup.make("server.permission") + .add( + HttpApiEndpoint.get("permission.request.list", "/api/permission/request", { + query: LocationQuery, + success: Location.response(Schema.Array(Permission.Request)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.request.list", + summary: "List pending permission requests", + description: "Retrieve pending permission requests for a location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", { + query: Schema.Struct({ projectID: Project.ID.pipe(Schema.optional) }), + success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.saved.list", + summary: "List saved permissions", + description: "Retrieve saved permissions, optionally filtered by project.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", { + params: { id: PermissionSaved.ID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.saved.remove", + summary: "Remove saved permission", + description: "Remove a saved permission by ID.", + }), + ), + ) + // Effect applies group middleware only to endpoints already added; session endpoints use session placement below. + .middleware(locationMiddleware) + .add( + HttpApiEndpoint.post("session.permission.create", "/api/session/:sessionID/permission", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: Permission.ID.pipe(Schema.optional), + action: Permission.Request.fields.action, + resources: Permission.Request.fields.resources, + save: Permission.Request.fields.save, + metadata: Permission.Request.fields.metadata, + source: Permission.Request.fields.source, + agent: Agent.ID.pipe(Schema.optional), + }), + success: Schema.Struct({ + data: Schema.Struct({ id: Permission.ID, effect: Permission.Effect }), + }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.create", + summary: "Create permission request", + description: "Evaluate and, when approval is required, create a permission request for a session.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(Permission.Request) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.list", + summary: "List session permission requests", + description: "Retrieve pending permission requests owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", { + params: { sessionID: Session.ID, requestID: Permission.ID }, + success: Schema.Struct({ data: Permission.Request }), + error: [SessionNotFoundError, PermissionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.get", + summary: "Get permission request", + description: "Retrieve a pending permission request owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", { + params: { sessionID: Session.ID, requestID: Permission.ID }, + payload: Schema.Struct({ + reply: Permission.Reply, + message: Schema.String.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, PermissionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.reply", + summary: "Reply to pending permission request", + description: "Respond to a pending permission request owned by a session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) diff --git a/packages/server/src/groups/project-copy.ts b/packages/protocol/src/groups/project-copy.ts similarity index 83% rename from packages/server/src/groups/project-copy.ts rename to packages/protocol/src/groups/project-copy.ts index e9b5e8f6748..c4f0240fe33 100644 --- a/packages/server/src/groups/project-copy.ts +++ b/packages/protocol/src/groups/project-copy.ts @@ -1,8 +1,8 @@ -import { ProjectCopy } from "@opencode-ai/core/project/copy" -import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectCopy } from "@opencode-ai/schema/project-copy" +import { Project } from "@opencode-ai/schema/project" import { Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" const root = "/experimental/project/:projectID/copy" @@ -23,7 +23,7 @@ const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") .add( HttpApiEndpoint.post("projectCopy.create", root, { - params: { projectID: ProjectV2.ID }, + params: { projectID: Project.ID }, query: LocationQuery, payload: CreatePayload, success: ProjectCopy.Copy, @@ -34,7 +34,7 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") ) .add( HttpApiEndpoint.delete("projectCopy.remove", root, { - params: { projectID: ProjectV2.ID }, + params: { projectID: Project.ID }, query: LocationQuery, payload: RemovePayload, success: HttpApiSchema.NoContent, @@ -45,7 +45,7 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") ) .add( HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, { - params: { projectID: ProjectV2.ID }, + params: { projectID: Project.ID }, query: LocationQuery, success: HttpApiSchema.NoContent, error: ProjectCopyError, @@ -54,4 +54,3 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy") .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })), ) .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." })) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/provider.ts b/packages/protocol/src/groups/provider.ts similarity index 77% rename from packages/server/src/groups/provider.ts rename to packages/protocol/src/groups/provider.ts index 4b861ccc9e8..9089b1a09c7 100644 --- a/packages/server/src/groups/provider.ts +++ b/packages/protocol/src/groups/provider.ts @@ -1,15 +1,15 @@ -import { ProviderV2 } from "@opencode-ai/core/provider" -import { Location } from "@opencode-ai/core/location" +import { Provider } from "@opencode-ai/schema/provider" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ProviderNotFoundError, ServiceUnavailableError } from "../errors" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const ProviderGroup = HttpApiGroup.make("server.provider") .add( HttpApiEndpoint.get("provider.list", "/api/provider", { query: LocationQuery, - success: Location.response(Schema.Array(ProviderV2.Info)), + success: Location.response(Schema.Array(Provider.Info)), error: ServiceUnavailableError, }) .annotateMerge(locationQueryOpenApi) @@ -23,9 +23,9 @@ export const ProviderGroup = HttpApiGroup.make("server.provider") ) .add( HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", { - params: { providerID: ProviderV2.ID }, + params: { providerID: Provider.ID }, query: LocationQuery, - success: Location.response(ProviderV2.Info), + success: Location.response(Provider.Info), error: [ProviderNotFoundError, ServiceUnavailableError], }) .annotateMerge(locationQueryOpenApi) @@ -43,4 +43,3 @@ export const ProviderGroup = HttpApiGroup.make("server.provider") description: "Experimental provider routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts similarity index 90% rename from packages/server/src/groups/pty.ts rename to packages/protocol/src/groups/pty.ts index 3e6cb4631d2..013f8e791c2 100644 --- a/packages/server/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -1,11 +1,10 @@ -import { Pty } from "@opencode-ai/core/pty" -import { PtyID } from "@opencode-ai/core/pty/schema" -import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { Location } from "@opencode-ai/core/location" +import { Pty } from "@opencode-ai/schema/pty" +import { PtyTicket } from "@opencode-ai/schema/pty-ticket" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ForbiddenError, PtyNotFoundError } from "../errors" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const PTY_CONNECT_TICKET_QUERY = "ticket" export const PTY_CONNECT_TOKEN_HEADER = "x-kilo-ticket" @@ -52,7 +51,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ) .add( HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, query: LocationQuery, success: Location.response(Pty.Info), error: PtyNotFoundError, @@ -68,7 +67,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ) .add( HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, query: LocationQuery, payload: Pty.UpdateInput, success: Location.response(Pty.Info), @@ -85,7 +84,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ) .add( HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, query: LocationQuery, success: HttpApiSchema.NoContent, error: PtyNotFoundError, @@ -101,7 +100,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ) .add( HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, query: LocationQuery, success: Location.response(PtyTicket.ConnectToken), error: [ForbiddenError, PtyNotFoundError], @@ -119,7 +118,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") // Query fields are decoded in the raw handler after the existence check so a missing // session responds with an empty 404 before any upgrade work. HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", { - params: { ptyID: PtyID }, + params: { ptyID: Pty.ID }, success: Schema.Boolean, error: [ForbiddenError, PtyNotFoundError], }).annotateMerge( @@ -148,4 +147,3 @@ export const PtyGroup = HttpApiGroup.make("server.pty") ), ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." })) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/question.ts b/packages/protocol/src/groups/question.ts new file mode 100644 index 00000000000..e6d862334d8 --- /dev/null +++ b/packages/protocol/src/groups/question.ts @@ -0,0 +1,84 @@ +import { Question } from "@opencode-ai/schema/question" +import { Location } from "@opencode-ai/schema/location" +import { Session } from "@opencode-ai/schema/session" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { QuestionNotFoundError, SessionNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const makeQuestionGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApiGroup.make("server.question") + .add( + HttpApiEndpoint.get("question.request.list", "/api/question/request", { + query: LocationQuery, + success: Location.response(Schema.Array(Question.Request)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.question.request.list", + summary: "List pending question requests", + description: "Retrieve pending question requests for a location.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." })) + // Effect applies group middleware only to endpoints already added; session endpoints use session placement below. + .middleware(locationMiddleware) + .add( + HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(Question.Request) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.list", + summary: "List session question requests", + description: "Retrieve pending question requests owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", { + params: { sessionID: Session.ID, requestID: Question.ID }, + payload: Question.Reply, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, QuestionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.reply", + summary: "Reply to pending question request", + description: "Answer a pending question request owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", { + params: { sessionID: Session.ID, requestID: Question.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, QuestionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.question.reject", + summary: "Reject pending question request", + description: "Reject a pending question request owned by a session.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }), + ) diff --git a/packages/server/src/groups/reference.ts b/packages/protocol/src/groups/reference.ts similarity index 77% rename from packages/server/src/groups/reference.ts rename to packages/protocol/src/groups/reference.ts index f27a934db2e..d953cd530a8 100644 --- a/packages/server/src/groups/reference.ts +++ b/packages/protocol/src/groups/reference.ts @@ -1,8 +1,8 @@ -import { Location } from "@opencode-ai/core/location" -import { Reference } from "@opencode-ai/core/reference" +import { Location } from "@opencode-ai/schema/location" +import { Reference } from "@opencode-ai/schema/reference" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const ReferenceGroup = HttpApiGroup.make("server.reference") .add( @@ -25,4 +25,3 @@ export const ReferenceGroup = HttpApiGroup.make("server.reference") description: "Location-scoped project references.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts new file mode 100644 index 00000000000..08fd8f46281 --- /dev/null +++ b/packages/protocol/src/groups/session.ts @@ -0,0 +1,390 @@ +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { PromptInput } from "@opencode-ai/schema/prompt-input" +import { Session } from "@opencode-ai/schema/session" +import { Project } from "@opencode-ai/schema/project" +import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" +import { Workspace } from "@opencode-ai/schema/workspace" +import { Context, Effect, Encoding, Result, Schema, Struct } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { + ConflictError, + InvalidCursorError, + InvalidRequestError, + MessageNotFoundError, + ServiceUnavailableError, + SessionNotFoundError, + UnknownError, +} from "../errors" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" +import { Location } from "@opencode-ai/schema/location" +import { Revert } from "@opencode-ai/schema/revert" +import { SessionEvent } from "@opencode-ai/schema/session-event" +import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" // kilocode_change - released history keys + +const SessionsQueryFields = { + workspace: Workspace.ID.pipe(Schema.optional), + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + search: Schema.optional(Schema.String), +} + +const SessionsDirectoryQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath, +}) + +const SessionsProjectQuery = Schema.Struct({ + ...SessionsQueryFields, + project: Project.ID, + subpath: RelativePath.pipe(Schema.optional), +}) + +const SessionsAllQuery = Schema.Struct(SessionsQueryFields) + +const withCursor = (schema: Schema.Struct) => + schema.mapFields((fields) => ({ + ...Struct.omit(fields, ["limit"]), + anchor: Session.ListAnchor, + })) + +const SessionsCursorInput = Schema.Union([ + withCursor(SessionsDirectoryQuery), + withCursor(SessionsProjectQuery), + withCursor(SessionsAllQuery), +]) +const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) +const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) +const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) +const invalidCursor = "Invalid cursor" as const + +export const SessionsCursor = Schema.String.pipe( + Schema.brand("SessionsCursor"), + statics((schema) => { + const make = schema.make.bind(schema) + return { + make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))), + parse: (input: string) => + Effect.suspend(() => { + const result = Encoding.decodeBase64UrlString(input) + return Result.isFailure(result) + ? Effect.fail(invalidCursor) + : decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor)) + }), + } + }), +) +export type SessionsCursor = typeof SessionsCursor.Type + +const SessionActive = Schema.Struct({ + type: Schema.Literal("running"), +}).annotate({ identifier: "SessionActive" }) + +const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) + +export const SessionHistoryQuery = Schema.Struct({ + limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), +}) + +const SessionsQueryCursor = SessionsCursor.annotate({ + description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", +}) + +export const SessionsQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath.pipe(Schema.optional), + project: Project.ID.pipe(Schema.optional), + subpath: RelativePath.pipe(Schema.optional), + cursor: SessionsQueryCursor.pipe(Schema.optional), +}).annotate({ identifier: "SessionsQuery" }) + +export const makeSessionGroup = < + I extends HttpApiMiddleware.AnyId, + S, + LocationId extends HttpApiMiddleware.AnyId, + LocationService, +>( + sessionLocationMiddleware: Context.Key, + locationMiddleware: Context.Key, // kilocode_change - supply the configured location for session creation +) => + HttpApiGroup.make("server.session") + .add( + HttpApiEndpoint.get("session.list", "/api/session", { + query: SessionsQuery, + success: Schema.Struct({ + data: Schema.Array(Session.Info), + cursor: Schema.Struct({ + previous: SessionsCursor.pipe(Schema.optional), + next: SessionsCursor.pipe(Schema.optional), + }), + }).annotate({ identifier: "SessionsResponse" }), + error: [InvalidCursorError, InvalidRequestError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.list", + summary: "List sessions", + description: + "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.create", "/api/session", { + payload: Schema.Struct({ + id: Session.ID.pipe(Schema.optional), + agent: Agent.ID.pipe(Schema.optional), + model: Model.Ref.pipe(Schema.optional), + location: Location.Ref.pipe(Schema.optional), + }), + success: Schema.Struct({ data: Session.Info }), + }) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.create", + summary: "Create session", + description: "Create a session at the requested location.", + }), + ) + .middleware(locationMiddleware), // kilocode_change - use the SDK's configured directory when payload location is omitted + ) + .add( + HttpApiEndpoint.get("session.active", "/api/session/active", { + success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.active", + summary: "List active sessions", + description: + "Retrieve foreground Session drains currently owned by this Kilo process. Sessions absent from the result are inactive.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Session.Info }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.get", + summary: "Get session", + description: "Retrieve a session by ID.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ agent: Agent.ID }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchAgent", + summary: "Switch session agent", + description: "Switch the agent used by subsequent provider turns.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ model: Model.Ref }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchModel", + summary: "Switch session model", + description: "Switch the model used by subsequent provider turns.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), + prompt: PromptInput.Prompt, + delivery: SessionInput.Delivery.pipe(Schema.optional), + resume: Schema.Boolean.pipe(Schema.optional), + }), + success: Schema.Struct({ data: SessionInput.Admitted }), + error: [ConflictError, SessionNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send message", + description: "Durably admit one session input and schedule agent-loop execution unless resume is false.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.compact", + summary: "Compact session", + description: "Compact a session conversation.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.wait", + summary: "Wait for session", + description: "Wait for a session agent loop to become idle.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), + success: Schema.Struct({ data: Revert.State }), + error: [MessageNotFoundError, SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.revert.stage", + summary: "Stage session revert", + description: "Stage or move a reversible session boundary and optionally apply its file changes.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })), + ) + .add( + HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" }), + ), + ) + .add( + HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), + error: [SessionNotFoundError, UnknownError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context", + summary: "Get session context", + description: "Retrieve the active context messages for a session (all messages after the last compaction).", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", { + params: { sessionID: Session.ID }, + query: SessionHistoryQuery, + success: Schema.Struct({ + data: Schema.Array(SessionDurable.schema), // kilocode_change + hasMore: Schema.Boolean, + }).annotate({ identifier: "SessionHistory" }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.history", + summary: "Get session history", + description: + "Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", { + params: { sessionID: Session.ID }, + query: { + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), + }, + success: HttpApiSchema.StreamSse({ data: SessionDurable.schema }), // kilocode_change + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.events", + summary: "Subscribe to session events", + description: "Replay durable events after an aggregate sequence, then continue with new durable events.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.interrupt", + summary: "Interrupt session execution", + description: "Interrupt active execution owned by this Kilo process. Idle interruption is a no-op.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", { + params: { sessionID: Session.ID, messageID: SessionMessage.ID }, + success: Schema.Struct({ data: SessionMessage.Message }), + error: [SessionNotFoundError, MessageNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.message", + summary: "Get session message", + description: "Retrieve one projected message owned by the Session.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "sessions", + description: "Experimental session routes.", + }), + ) diff --git a/packages/server/src/groups/skill.ts b/packages/protocol/src/groups/skill.ts similarity index 69% rename from packages/server/src/groups/skill.ts rename to packages/protocol/src/groups/skill.ts index a43cb83c525..ab998a538e0 100644 --- a/packages/server/src/groups/skill.ts +++ b/packages/protocol/src/groups/skill.ts @@ -1,14 +1,14 @@ -import { SkillV2 } from "@opencode-ai/core/skill" -import { Location } from "@opencode-ai/core/location" +import { Skill } from "@opencode-ai/schema/skill" +import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" +import { LocationQuery, locationQueryOpenApi } from "./location" export const SkillGroup = HttpApiGroup.make("server.skill") .add( HttpApiEndpoint.get("skill.list", "/api/skill", { query: LocationQuery, - success: Location.response(Schema.Array(SkillV2.Info)), + success: Location.response(Schema.Array(Skill.Info)), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( @@ -25,4 +25,3 @@ export const SkillGroup = HttpApiGroup.make("server.skill") description: "Experimental skill routes.", }), ) - .middleware(LocationMiddleware) diff --git a/packages/protocol/src/middleware/authorization.ts b/packages/protocol/src/middleware/authorization.ts new file mode 100644 index 00000000000..ed1c3caf667 --- /dev/null +++ b/packages/protocol/src/middleware/authorization.ts @@ -0,0 +1,6 @@ +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { UnauthorizedError } from "../errors" + +export class Authorization extends HttpApiMiddleware.Service()("@opencode/HttpApiAuthorization", { + error: UnauthorizedError, +}) {} diff --git a/packages/protocol/src/middleware/schema-error.ts b/packages/protocol/src/middleware/schema-error.ts new file mode 100644 index 00000000000..635ecec197b --- /dev/null +++ b/packages/protocol/src/middleware/schema-error.ts @@ -0,0 +1,7 @@ +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { InvalidRequestError } from "../errors" + +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiSchemaError", + { error: InvalidRequestError }, +) {} diff --git a/packages/protocol/sst-env.d.ts b/packages/protocol/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/packages/protocol/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/protocol/test/session-cursor.test.ts b/packages/protocol/test/session-cursor.test.ts new file mode 100644 index 00000000000..2680c962e14 --- /dev/null +++ b/packages/protocol/test/session-cursor.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { SessionHistoryQuery, SessionsCursor } from "../src/groups/session" +import { Session } from "@opencode-ai/schema/session" + +describe("SessionsCursor", () => { + test("round trips without Node globals", async () => { + const input = { + workspace: undefined, + search: "protocol", + order: "desc" as const, + anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const }, + } + const cursor = SessionsCursor.make(input) + + expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input) + }) +}) + +describe("SessionHistoryQuery", () => { + test("decodes numeric paging inputs", async () => { + const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" })) + + expect(query).toEqual({ after: 3, limit: 10 }) + }) +}) diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 00000000000..681f85136aa --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "types": ["bun"] + } +} diff --git a/packages/schema/AGENTS.md b/packages/schema/AGENTS.md new file mode 100644 index 00000000000..1d5747d5399 --- /dev/null +++ b/packages/schema/AGENTS.md @@ -0,0 +1,88 @@ +# Schema Package Guide + +`@opencode-ai/schema` owns browser-safe wire and storage contracts shared by protocol, server, core, and generated SDKs. Keep runtime behavior, service layers, side effects, and host-local implementation details in the domain package that owns them. + +## Package Boundary + +- Preserve the dependency direction: `@opencode-ai/schema <- @opencode-ai/protocol <- @opencode-ai/server`. +- Schema values should be serializable contract definitions, not service implementations or runtime registries. +- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.added` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema. +- The root barrel exports canonical current domain contracts. Specialized event modules, manifests, infrastructure modules, and V1 contracts use direct entrypoints instead of becoming first-class root exports. + +## Current Versus V1 + +- Current contracts are unversioned: use names like `Session`, `Permission`, `Question`, and identifiers like `Permission.Request`. +- Legacy contracts retained for active compatibility, persistence, or migration are explicitly `V1`: use names like `SessionV1`, `PermissionV1`, and identifiers like `PermissionV1.Request`. +- Do not preserve `V2` as the permanent name for the replacement architecture. Remove `V2` from current namespaces, brands, and identifiers as the contracts are normalized. +- Retained V1 contracts should live under a dedicated `src/v1/` subtree once the V1 isolation PR runs. New/current code must not depend on that subtree. +- V1 coexistence is temporary. Keep compatibility entrypoints only where migration requires them, and delete the V1 subtree when the legacy runtime is retired. +- `@opencode-ai/protocol` and `@opencode-ai/sdk-next` are current `/api/...` surfaces. + +## Events + +- Classify event definitions by protocol role before adding them to a public manifest: `current`, `shared transitional`, or `V1-only`. +- Being emitted by V1 is not enough to include an event in Protocol or SDK Next. +- Keep clearly V1-only events, such as `message.updated` and `message.part.*`, out of the current Protocol/SDK Next event surface unless a current-client requirement is documented. +- Keep compatibility events available only to the existing App/TUI/CLI compatibility surface while they are still needed. +- Preserve a single canonical event definition. Do not duplicate definitions for generation convenience. + +## Module Shape + +- Use one canonical exported value for each contract. Avoid bridge aliases such as `PluginID`, `PluginEvent`, `PtyInfo`, `PtyEvent`, and `SessionTodoInfo`. +- Prefer importing the schema module namespace and reading canonical members, for example `Plugin.ID` or `SessionTodo.Info`. +- Core may compose Schema contracts with runtime behavior into a deliberate domain facade, but the facade must re-export the exact canonical Schema value. Do not create a second schema identity. +- Use flat top-level exports plus the package's existing namespace projection pattern, for example `export * as SessionMessage from "./session-message"`. +- Keep standalone ID modules only when they prevent real cycles or heavy dependency edges. Inline one-off IDs into their owning contract module when no cycle exists. + +## Naming + +- Exported schema values and namespace objects use `PascalCase`. +- Schema-building functions and combinators use `camelCase`. +- The package's static-method combinator is `statics(...)`. +- Keep descriptive schema value names such as `PositiveInt`, `NonNegativeInt`, `AbsolutePath`, `RelativePath`, and `DateTimeUtcFromMillis`. + +## Optional Fields And Defaults + +- Use the package `optional(...)` helper for optional object properties, including nested structs and event payloads, so encoded objects omit `undefined` keys. +- Use raw `Schema.optional(...)` only when preserving `undefined` as an explicitly encoded property is intentional and documented. +- External convenience defaults are normally decode-only with `Schema.withDecodingDefault(...)`. +- Add constructor defaults only when the domain value itself requires construction-time normalization. + +## Public Types + +- Public `Schema.Struct` records use same-name interfaces: + + ```ts + export interface Info extends Schema.Schema.Type {} + export const Info = Schema.Struct({ ... }) + ``` + +- Use type aliases for unions, scalars, arrays, branded scalar types, and event payload helper types. +- Closed documented string sets use `Schema.Literals(...)`. If arbitrary strings are valid, document the field as arbitrary rather than listing a closed set. + +## Mutability + +- Public Schema contracts are readonly by default. +- Do not use `Schema.mutable(...)` in public contracts for runtime convenience. +- Runtime code that needs mutation should opt in at the boundary with `Types.DeepMutable`, a purpose-built draft type, or another explicit mutable API. + +## Unknown Values + +- Current public contracts avoid `Schema.Any`. +- Use `Schema.Json` for values that must be JSON-serializable. +- Use `Schema.Unknown` for genuinely opaque values that require consumer-side narrowing. +- Keep `Schema.Any` only at an explicitly unsafe compatibility boundary with a documented reason. + +## IDs And Identifiers + +- Current ID constructors expose `create()`. +- Directional constructors such as `ascending()` or `descending()` remain only where ordering semantics are part of the public contract or compatibility requires the old method. +- New generated ID schemas must validate exactly the prefix they emit, including the underscore. +- Do not tighten legacy loose ID validators without an explicit compatibility and migration decision; existing callers and tests may rely on accepted non-canonical IDs. +- Reusable exported public schemas get stable, domain-qualified identifiers such as `Model.Ref` or `Agent.Color`. +- Public schema identifiers and brands must be unique and stable. Private one-use nested schemas may remain anonymous. + +## Tests For Contract Changes + +- Add focused tests when changing contract behavior or generated surface. +- Cover optional properties omitting `undefined`, no accidental current-contract `Schema.Any`, stable and unique public identifiers, exact facade/schema identity, and current Protocol manifests excluding V1-only events. diff --git a/packages/schema/package.json b/packages/schema/package.json new file mode 100644 index 00000000000..577df3666b8 --- /dev/null +++ b/packages/schema/package.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/schema", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "version": "7.4.16" +} diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts new file mode 100644 index 00000000000..adf7aec2823 --- /dev/null +++ b/packages/schema/src/agent.ts @@ -0,0 +1,38 @@ +export * as Agent from "./agent" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Model } from "./model" +import { Permission } from "./permission" +import { Provider } from "./provider" +import { PositiveInt, statics } from "./schema" + +export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +export type ID = typeof ID.Type + +export const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]).annotate({ identifier: "Agent.Color" }) +export type Color = typeof Color.Type + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + model: Model.Ref.pipe(optional), + request: Provider.Request, + system: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + mode: Schema.Literals(["subagent", "primary", "all"]), + hidden: Schema.Boolean, + color: Color.pipe(optional), + steps: PositiveInt.pipe(optional), + permissions: Permission.Ruleset, +}) + .annotate({ identifier: "AgentV2.Info" }) + .pipe( + statics((schema) => ({ + empty: (id: ID) => + schema.make({ id, request: { headers: {}, body: {} }, mode: "all", hidden: false, permissions: [] }), + })), + ) diff --git a/packages/schema/src/catalog.ts b/packages/schema/src/catalog.ts new file mode 100644 index 00000000000..54abb5b1280 --- /dev/null +++ b/packages/schema/src/catalog.ts @@ -0,0 +1,6 @@ +export * as Catalog from "./catalog" + +import { define, inventory } from "./event" + +const Updated = define({ type: "catalog.updated", schema: {} }) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts new file mode 100644 index 00000000000..0527f3f8103 --- /dev/null +++ b/packages/schema/src/command.ts @@ -0,0 +1,16 @@ +export * as Command from "./command" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Model } from "./model" + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + name: Schema.String, + template: Schema.String, + description: Schema.String.pipe(optional), + agent: Schema.String.pipe(optional), + model: Model.Ref.pipe(optional), + variant: Model.VariantID.pipe(optional), // kilocode_change - support variant-only command overrides + subtask: Schema.Boolean.pipe(optional), +}).annotate({ identifier: "CommandV2.Info" }) diff --git a/packages/schema/src/connection.ts b/packages/schema/src/connection.ts new file mode 100644 index 00000000000..2bacb977a87 --- /dev/null +++ b/packages/schema/src/connection.ts @@ -0,0 +1,22 @@ +export * as Connection from "./connection" + +import { Schema } from "effect" +import { Credential } from "./credential" + +export interface CredentialInfo extends Schema.Schema.Type {} +export const CredentialInfo = Schema.Struct({ + type: Schema.Literal("credential"), + id: Credential.ID, + label: Schema.String, +}).annotate({ identifier: "Connection.CredentialInfo" }) + +export interface EnvInfo extends Schema.Schema.Type {} +export const EnvInfo = Schema.Struct({ + type: Schema.Literal("env"), + name: Schema.String, +}).annotate({ identifier: "Connection.EnvInfo" }) + +export const Info = Schema.Union([CredentialInfo, EnvInfo]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Connection.Info" }) +export type Info = typeof Info.Type diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts new file mode 100644 index 00000000000..e54e2f680b7 --- /dev/null +++ b/packages/schema/src/credential.ts @@ -0,0 +1,35 @@ +export * as Credential from "./credential" + +import { Schema } from "effect" +import { optional } from "./schema" +import { IntegrationMethodID } from "./integration-id" +import { ascending } from "./identifier" +import { NonNegativeInt, statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("Credential.ID"), + statics((schema) => ({ create: () => schema.make("cred_" + ascending()) })), +) +export type ID = typeof ID.Type + +export interface OAuth extends Schema.Schema.Type {} +export const OAuth = Schema.Struct({ + type: Schema.Literal("oauth"), + methodID: IntegrationMethodID, + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "Credential.OAuth" }) + +export interface Key extends Schema.Schema.Type {} +export const Key = Schema.Struct({ + type: Schema.Literal("key"), + key: Schema.String, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "Credential.Key" }) + +export const Value = Schema.Union([OAuth, Key]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Credential.Value" }) +export type Value = Schema.Schema.Type diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts new file mode 100644 index 00000000000..406ad976bf6 --- /dev/null +++ b/packages/schema/src/durable-event-manifest.ts @@ -0,0 +1,25 @@ +export * as DurableEventManifest from "./durable-event-manifest" + +import { Event } from "./event" +import { Schema } from "effect" // kilocode_change +import { SessionEvent } from "./session-event" +import { SessionV1 } from "./session-v1" +import { PromptPromoted } from "./kilocode/durable-event" // kilocode_change - released storage key + +// kilocode_change start - retain the released prompt promotion event for history and replay +const definitions = Event.inventory(...SessionEvent.DurableDefinitions, PromptPromoted) +const schema = Schema.Union(definitions, { mode: "oneOf" }) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "SessionDurableEvent" }) +export type SessionDurableEvent = typeof schema.Type +// kilocode_change end + +export const SessionDurable = { + definitions: Event.durable(definitions), // kilocode_change + schema, // kilocode_change +} as const + +export const Durable = Event.durable([ + ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined), + ...definitions, // kilocode_change +]) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts new file mode 100644 index 00000000000..b681362e83b --- /dev/null +++ b/packages/schema/src/event-manifest.ts @@ -0,0 +1,84 @@ +export * as EventManifest from "./event-manifest" + +import { Catalog } from "./catalog" +import { Durable } from "./durable-event-manifest" +import { Event } from "./event" +import { FileSystem } from "./filesystem" +import { FileSystemWatcher } from "./filesystem-watcher" +import { InstallationEvent } from "./installation-event" +import { Integration } from "./integration" +import { LegacyEvent } from "./legacy-event" +import { LspEvent } from "./lsp-event" +import { McpEvent } from "./mcp-event" +import { ModelsDev } from "./models-dev" +import { Permission } from "./permission" +import { PermissionV1 } from "./permission-v1" +import { Plugin } from "./plugin" +import { Project } from "./project" +import { ProjectDirectories } from "./project-directories" +import { Pty } from "./pty" +import { Question } from "./question" +import { QuestionV1 } from "./question-v1" +import { Reference } from "./reference" +import { ServerEvent } from "./server-event" +import { SessionCompactionEvent } from "./session-compaction-event" +import { SessionEvent } from "./session-event" +import { SessionStatusEvent } from "./session-status-event" +import { SessionTodo } from "./session-todo" +import { SessionV1 } from "./session-v1" +import { TuiEvent } from "./tui-event" +import { VcsEvent } from "./vcs-event" +import { WorkspaceEvent } from "./workspace-event" +import { WorktreeEvent } from "./worktree-event" + +const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined) +const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable === undefined) + +const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions) + +const foundationDefinitions = Event.inventory( + ...ModelsDev.Event.Definitions, + ...Integration.Event.Definitions, + ...Catalog.Event.Definitions, + ...coreDefinitions, +) + +const featureDefinitions = Event.inventory( + ...FileSystem.Event.Definitions, + ...Reference.Event.Definitions, + ...Permission.Event.Definitions, + ...Plugin.Event.Definitions, + ...ProjectDirectories.Event.Definitions, + ...FileSystemWatcher.Event.Definitions, + ...Pty.Event.Definitions, + ...Question.Event.Definitions, +) + +export const ServerDefinitions = Event.inventory( + ...foundationDefinitions, + ...featureDefinitions, + ...SessionTodo.Event.Definitions, +) + +export const Definitions = Event.inventory( + ...foundationDefinitions, + ...sessionV1LiveDefinitions, + ...InstallationEvent.Definitions, + ...featureDefinitions, + ...SessionTodo.Event.Definitions, + ...LspEvent.Definitions, + ...PermissionV1.Event.Definitions, + ...TuiEvent.Definitions, + ...McpEvent.Definitions, + ...LegacyEvent.Definitions, + ...Project.Event.Definitions, + ...SessionStatusEvent.Definitions, + ...QuestionV1.Event.Definitions, + ...SessionCompactionEvent.Definitions, + ...VcsEvent.Definitions, + ...WorkspaceEvent.Definitions, + ...WorktreeEvent.Definitions, + ...ServerEvent.Definitions, +) +export const Latest = Event.latest(Definitions) +export { Durable } diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts new file mode 100644 index 00000000000..0d6ec9775aa --- /dev/null +++ b/packages/schema/src/event.ts @@ -0,0 +1,125 @@ +export * as Event from "./event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { ascending } from "./identifier" +import { Location } from "./location" +import { statics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( + Schema.brand("Event.ID"), + statics((schema) => ({ create: () => schema.make("evt_" + ascending()) })), +) +export type ID = typeof ID.Type + +export type Definition< + Type extends string = string, + DataSchema extends Schema.Codec = Schema.Codec, +> = Schema.Top & { + readonly type: Type + readonly durable?: { + readonly version: number + readonly aggregate: string + } + readonly data: DataSchema +} + +export type Data = Schema.Schema.Type + +export type Payload = { + readonly id: ID + readonly type: D["type"] + readonly data: Data + readonly durable?: { + readonly aggregateID: string + readonly seq: number + readonly version: number + } + readonly location?: Location.Ref + readonly metadata?: Record +} + +export function define< + const Type extends string, + const Fields extends Readonly>>, +>(input: { + readonly type: Type + readonly durable?: { + readonly version: number + readonly aggregate: string + } + readonly schema: Fields +}) { + const data = Schema.Struct(input.schema) + return Schema.Struct({ + id: ID, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), + location: optional(Location.Ref), + data, + }) + .annotate({ identifier: input.type }) + .pipe( + statics(() => ({ + type: input.type, + ...(input.durable === undefined ? {} : { durable: input.durable }), + data, + })), + ) satisfies Definition +} + +export function inventory>(...definitions: Definitions) { + return Object.freeze(definitions) +} + +export function latest(definitions: ReadonlyArray) { + return readonlyMap( + definitions.reduce((result, definition) => { + const existing = result.get(definition.type) + if (!existing) { + result.set(definition.type, definition) + return result + } + if (definition.durable && existing.durable && definition.durable.version !== existing.durable.version) { + if (definition.durable.version > existing.durable.version) result.set(definition.type, definition) + return result + } + if (definition !== existing) throw new Error(`Duplicate latest event definition for ${definition.type}`) + return result + }, new Map()), + ) +} + +export function versionedType(type: string, version: number) { + return `${type}.${version}` +} + +export function durable>(definitions: Definitions) { + return readonlyMap( + definitions.reduce((result, definition) => { + if (!definition.durable) return result + const key = versionedType(definition.type, definition.durable.version) + if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`) + result.set(key, definition) + return result + }, new Map()), + ) +} + +function readonlyMap(map: Map): ReadonlyMap { + const result: ReadonlyMap = Object.freeze({ + get size() { + return map.size + }, + entries: () => map.entries(), + forEach: (callback: (value: Value, key: Key, map: ReadonlyMap) => void, thisArg?: unknown) => + map.forEach((value, key) => callback.call(thisArg, value, key, result)), + get: (key: Key) => map.get(key), + has: (key: Key) => map.has(key), + keys: () => map.keys(), + values: () => map.values(), + [Symbol.iterator]: () => map[Symbol.iterator](), + }) + return result +} diff --git a/packages/schema/src/file-diff.ts b/packages/schema/src/file-diff.ts new file mode 100644 index 00000000000..45226df28ee --- /dev/null +++ b/packages/schema/src/file-diff.ts @@ -0,0 +1,13 @@ +export * as FileDiff from "./file-diff" + +import { Schema } from "effect" +import { optional } from "./schema" + +export const Info = Schema.Struct({ + file: optional(Schema.String), + patch: optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, + status: optional(Schema.Literals(["added", "deleted", "modified"])), +}).annotate({ identifier: "SnapshotFileDiff" }) +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/filesystem-watcher.ts b/packages/schema/src/filesystem-watcher.ts new file mode 100644 index 00000000000..5e4da777cae --- /dev/null +++ b/packages/schema/src/filesystem-watcher.ts @@ -0,0 +1,13 @@ +export * as FileSystemWatcher from "./filesystem-watcher" + +import { Schema } from "effect" +import { define, inventory } from "./event" + +const Updated = define({ + type: "file.watcher.updated", + schema: { + file: Schema.String, + event: Schema.Literals(["add", "change", "unlink"]), + }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/filesystem.ts b/packages/schema/src/filesystem.ts new file mode 100644 index 00000000000..1998c1b9fd6 --- /dev/null +++ b/packages/schema/src/filesystem.ts @@ -0,0 +1,40 @@ +export * as FileSystem from "./filesystem" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" + +const Edited = define({ + type: "file.edited", + schema: { file: Schema.String }, +}) +export const Event = { Edited, Definitions: inventory(Edited) } + +export interface Entry extends Schema.Schema.Type {} +export const Entry = Schema.Struct({ + path: RelativePath, + type: Schema.Literals(["file", "directory"]), +}).annotate({ identifier: "FileSystem.Entry" }) + +export interface Submatch extends Schema.Schema.Type {} +export const Submatch = Schema.Struct({ + text: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, +}).annotate({ identifier: "FileSystem.Submatch" }) + +export interface Match extends Schema.Schema.Type {} +export const Match = Schema.Struct({ + entry: Entry, + line: PositiveInt, + offset: NonNegativeInt, + text: Schema.String, + submatches: Schema.Array(Submatch), +}).annotate({ identifier: "FileSystem.Match" }) + +export class FindInput extends Schema.Class("FileSystem.FindInput")({ + query: Schema.String, + type: Schema.Literals(["file", "directory"]).pipe(optional), + limit: PositiveInt.pipe(optional), +}) {} diff --git a/packages/schema/src/ide-event.ts b/packages/schema/src/ide-event.ts new file mode 100644 index 00000000000..ca421860214 --- /dev/null +++ b/packages/schema/src/ide-event.ts @@ -0,0 +1,13 @@ +export * as IdeEvent from "./ide-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const Installed = Event.define({ + type: "ide.installed", + schema: { + ide: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Installed) diff --git a/packages/schema/src/identifier.ts b/packages/schema/src/identifier.ts new file mode 100644 index 00000000000..9812a673fb0 --- /dev/null +++ b/packages/schema/src/identifier.ts @@ -0,0 +1,30 @@ +const length = 26 +const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +let lastTimestamp = 0 +let counter = 0 + +export function ascending() { + return create(false) +} + +export function descending() { + return create(true) +} + +export function create(descending: boolean, timestamp = Date.now()) { + if (timestamp !== lastTimestamp) { + lastTimestamp = timestamp + counter = 0 + } + counter++ + + const current = BigInt(timestamp) * 0x1000n + BigInt(counter) + const value = descending ? ~current : current + const time = Array.from({ length: 6 }, (_, index) => + Number((value >> BigInt(40 - 8 * index)) & 0xffn) + .toString(16) + .padStart(2, "0"), + ).join("") + const bytes = crypto.getRandomValues(new Uint8Array(length - 12)) + return time + Array.from(bytes, (byte) => chars[byte % 62]).join("") +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts new file mode 100644 index 00000000000..b7c8e5110f7 --- /dev/null +++ b/packages/schema/src/index.ts @@ -0,0 +1,28 @@ +export { Agent } from "./agent" +export { Command } from "./command" +export { Connection } from "./connection" +export { Credential } from "./credential" +export { Event } from "./event" +export { FileSystem } from "./filesystem" +export { Integration } from "./integration" +export { LLM } from "./llm" +export { Location } from "./location" +export { Model } from "./model" +export { Permission } from "./permission" +export { PermissionSaved } from "./permission-saved" +export { Project } from "./project" +export { ProjectCopy } from "./project-copy" +export { Provider } from "./provider" +export { Reference } from "./reference" +export { Revert } from "./revert" +export { Session } from "./session" +export { SessionInput } from "./session-input" +export { SessionMessage } from "./session-message" +export { Skill } from "./skill" +export { Pty } from "./pty" +export { PtyTicket } from "./pty-ticket" +export { Question } from "./question" +export { Workspace } from "./workspace" +export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt" +export { PromptInput } from "./prompt-input" +export * from "./schema" diff --git a/packages/schema/src/installation-event.ts b/packages/schema/src/installation-event.ts new file mode 100644 index 00000000000..69ecf670846 --- /dev/null +++ b/packages/schema/src/installation-event.ts @@ -0,0 +1,20 @@ +export * as InstallationEvent from "./installation-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const Updated = Event.define({ + type: "installation.updated", + schema: { + version: Schema.String, + }, +}) + +export const UpdateAvailable = Event.define({ + type: "installation.update-available", + schema: { + version: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Updated, UpdateAvailable) diff --git a/packages/schema/src/integration-id.ts b/packages/schema/src/integration-id.ts new file mode 100644 index 00000000000..2590dfe9f69 --- /dev/null +++ b/packages/schema/src/integration-id.ts @@ -0,0 +1,7 @@ +import { Schema } from "effect" + +export const IntegrationID = Schema.String.pipe(Schema.brand("Integration.ID")) +export type IntegrationID = typeof IntegrationID.Type + +export const IntegrationMethodID = Schema.String.pipe(Schema.brand("Integration.MethodID")) +export type IntegrationMethodID = typeof IntegrationMethodID.Type diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts new file mode 100644 index 00000000000..1345318da18 --- /dev/null +++ b/packages/schema/src/integration.ts @@ -0,0 +1,129 @@ +export * as Integration from "./integration" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { Connection } from "./connection" +import { ascending } from "./identifier" +import { statics } from "./schema" +import { IntegrationID, IntegrationMethodID } from "./integration-id" + +export const ID = IntegrationID +export type ID = typeof ID.Type + +export const MethodID = IntegrationMethodID +export type MethodID = typeof MethodID.Type + +export interface When extends Schema.Schema.Type {} +export const When = Schema.Struct({ + key: Schema.String, + op: Schema.Literals(["eq", "neq"]), + value: Schema.String, +}).annotate({ identifier: "Integration.When" }) + +export interface TextPrompt extends Schema.Schema.Type {} +export const TextPrompt = Schema.Struct({ + type: Schema.Literal("text"), + key: Schema.String, + message: Schema.String, + placeholder: optional(Schema.String), + when: optional(When), +}).annotate({ identifier: "Integration.TextPrompt" }) + +export interface SelectPrompt extends Schema.Schema.Type {} +export const SelectPrompt = Schema.Struct({ + type: Schema.Literal("select"), + key: Schema.String, + message: Schema.String, + options: Schema.Array( + Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: optional(Schema.String), + }), + ), + when: optional(When), +}).annotate({ identifier: "Integration.SelectPrompt" }) + +export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) +export type Prompt = typeof Prompt.Type + +export interface OAuthMethod extends Schema.Schema.Type {} +export const OAuthMethod = Schema.Struct({ + id: MethodID, + type: Schema.Literal("oauth"), + label: Schema.String, + prompts: optional(Schema.Array(Prompt)), +}).annotate({ identifier: "Integration.OAuthMethod" }) + +export interface KeyMethod extends Schema.Schema.Type {} +export const KeyMethod = Schema.Struct({ + type: Schema.Literal("key"), + label: optional(Schema.String), +}).annotate({ identifier: "Integration.KeyMethod" }) + +export interface EnvMethod extends Schema.Schema.Type {} +export const EnvMethod = Schema.Struct({ + type: Schema.Literal("env"), + names: Schema.Array(Schema.String), +}).annotate({ identifier: "Integration.EnvMethod" }) + +export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Integration.Method" }) +export type Method = typeof Method.Type + +export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" }) +export type Inputs = typeof Inputs.Type + +const Updated = define({ + type: "integration.updated", + schema: {}, +}) +const ConnectionUpdated = define({ + type: "integration.connection.updated", + schema: { integrationID: ID }, +}) +export const Event = { Updated, ConnectionUpdated, Definitions: inventory(Updated, ConnectionUpdated) } + +export interface Ref extends Schema.Schema.Type {} +export const Ref = Schema.Struct({ + id: ID, + name: Schema.String, +}).annotate({ identifier: "Integration.Ref" }) + +export class Info extends Schema.Class("Integration.Info")({ + id: ID, + name: Schema.String, + methods: Schema.Array(Method), + connections: Schema.Array(Connection.Info), +}) {} + +export const AttemptID = Schema.String.pipe( + Schema.brand("Integration.AttemptID"), + statics((schema) => ({ create: () => schema.make("con_" + ascending()) })), +) +export type AttemptID = typeof AttemptID.Type + +const AttemptTime = Schema.Struct({ + created: Schema.Number, + expires: Schema.Number, +}) + +export class Attempt extends Schema.Class("Integration.Attempt")({ + attemptID: AttemptID, + url: Schema.String, + instructions: Schema.String, + mode: Schema.Literals(["auto", "code"]), + time: AttemptTime, +}) {} + +export const AttemptStatus = Schema.Union([ + Schema.Struct({ status: Schema.Literal("pending"), time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("complete"), time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("expired"), time: AttemptTime }), +]) + .pipe(Schema.toTaggedUnion("status")) + .annotate({ identifier: "Integration.AttemptStatus" }) +export type AttemptStatus = typeof AttemptStatus.Type diff --git a/packages/schema/src/kilocode/durable-event.ts b/packages/schema/src/kilocode/durable-event.ts new file mode 100644 index 00000000000..a77525a7874 --- /dev/null +++ b/packages/schema/src/kilocode/durable-event.ts @@ -0,0 +1,17 @@ +import { Event } from "../event" +import { Prompt } from "../prompt" +import { DateTimeUtcFromMillis } from "../schema" +import { SessionID } from "../session-id" +import { SessionMessage } from "../session-message" + +export const PromptPromoted = Event.define({ + type: "session.next.prompt.promoted", + durable: { aggregate: "sessionID", version: 1 }, + schema: { + timestamp: DateTimeUtcFromMillis, + sessionID: SessionID, + messageID: SessionMessage.ID, + prompt: Prompt, + timeCreated: DateTimeUtcFromMillis, + }, +}) diff --git a/packages/schema/src/legacy-event.ts b/packages/schema/src/legacy-event.ts new file mode 100644 index 00000000000..c7115992a6c --- /dev/null +++ b/packages/schema/src/legacy-event.ts @@ -0,0 +1 @@ +export * from "./v1/legacy-event" diff --git a/packages/schema/src/llm.ts b/packages/schema/src/llm.ts new file mode 100644 index 00000000000..d67937ffe22 --- /dev/null +++ b/packages/schema/src/llm.ts @@ -0,0 +1,85 @@ +export * as LLM from "./llm" + +import { Schema, SchemaGetter } from "effect" +import { optional } from "./schema" + +export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({ + identifier: "LLM.ProviderMetadata", +}) +export type ProviderMetadata = Schema.Schema.Type + +export interface ToolTextContent extends Schema.Schema.Type {} +export const ToolTextContent = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, +}).annotate({ identifier: "Tool.TextContent" }) + +export interface ToolFileContent extends Schema.Schema.Type {} +export const ToolFileContent = Schema.Struct({ + type: Schema.Literal("file"), + uri: Schema.String, + mime: Schema.String, + name: optional(Schema.String), +}).annotate({ identifier: "Tool.FileContent" }) + +export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "LLM.ToolContent" }) +export type ToolContent = Schema.Schema.Type + +// kilocode_change start - durable events must keep decoding released tool content shapes +const LegacyToolFileContent = Schema.Struct({ + type: Schema.Literal("file"), + source: Schema.Union([ + Schema.Struct({ type: Schema.Literal("data"), data: Schema.String }), + Schema.Struct({ type: Schema.Literal("url"), url: Schema.String }), + Schema.Struct({ type: Schema.Literal("file"), uri: Schema.String }), + ]).pipe(Schema.toTaggedUnion("type")), + mime: Schema.String, + name: optional(Schema.String), +}) +const LegacyToolMediaContent = Schema.Struct({ + type: Schema.Literal("media"), + mediaType: Schema.String, + data: Schema.String, + filename: optional(Schema.String), +}) +const ToolContentInput = Schema.Union([ToolContent, LegacyToolFileContent, LegacyToolMediaContent]) + +/** Encodes current tool content back into the released persisted shape. */ +const stored = (item: ToolContent): typeof ToolContentInput.Type => { + if (item.type === "text") return item + const data = /^data:[^;,]+;base64,(.*)$/s.exec(item.uri)?.[1] + const source = data + ? ({ type: "data", data } as const) + : URL.canParse(item.uri) && ["http:", "https:"].includes(new URL(item.uri).protocol) + ? ({ type: "url", url: item.uri } as const) + : ({ type: "file", uri: item.uri } as const) + return { type: "file", source, mime: item.mime, name: item.name } +} + +/** Decodes released and current persisted tool content into the current `ToolContent` shape. */ +export const StoredToolContent = ToolContentInput.pipe( + Schema.decodeTo(ToolContent, { + decode: SchemaGetter.transform((item) => { + if (item.type === "text" || (item.type === "file" && "uri" in item)) return item + if (item.type === "media") + return { + type: "file" as const, + uri: item.data.startsWith("data:") ? item.data : `data:${item.mediaType};base64,${item.data}`, + mime: item.mediaType, + name: item.filename, + } + const source = item.source + const uri = + source.type === "data" + ? `data:${item.mime};base64,${source.data}` + : source.type === "url" + ? source.url + : source.uri + return { type: "file" as const, uri, mime: item.mime, name: item.name } + }), + encode: SchemaGetter.transform(stored), + }), +).annotate({ identifier: "LLM.StoredToolContent" }) +// kilocode_change end diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts new file mode 100644 index 00000000000..c01ce36372a --- /dev/null +++ b/packages/schema/src/location.ts @@ -0,0 +1,25 @@ +export * as Location from "./location" + +import { Schema } from "effect" +import { AbsolutePath, optional } from "./schema" +import { ProjectID } from "./project-id" +import { WorkspaceID } from "./workspace-id" + +export interface Ref extends Schema.Schema.Type {} +export const Ref = Schema.Struct({ + directory: AbsolutePath, + workspaceID: optional(WorkspaceID), +}).annotate({ identifier: "Location.Ref" }) + +export class Info extends Schema.Class("Location.Info")({ + directory: AbsolutePath, + workspaceID: optional(WorkspaceID), + project: Schema.Struct({ + id: ProjectID, + directory: AbsolutePath, + }), +}) {} + +export function response(data: S) { + return Schema.Struct({ location: Info, data }) +} diff --git a/packages/schema/src/lsp-event.ts b/packages/schema/src/lsp-event.ts new file mode 100644 index 00000000000..b6908469663 --- /dev/null +++ b/packages/schema/src/lsp-event.ts @@ -0,0 +1,7 @@ +export * as LspEvent from "./lsp-event" + +import { Event } from "./event" + +export const Updated = Event.define({ type: "lsp.updated", schema: {} }) + +export const Definitions = Event.inventory(Updated) diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts new file mode 100644 index 00000000000..1d050df9273 --- /dev/null +++ b/packages/schema/src/mcp-event.ts @@ -0,0 +1,21 @@ +export * as McpEvent from "./mcp-event" + +import { Schema } from "effect" +import { Event } from "./event" + +export const ToolsChanged = Event.define({ + type: "mcp.tools.changed", + schema: { + server: Schema.String, + }, +}) + +export const BrowserOpenFailed = Event.define({ + type: "mcp.browser.open.failed", + schema: { + mcpName: Schema.String, + url: Schema.String, + }, +}) + +export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts new file mode 100644 index 00000000000..10fa175e247 --- /dev/null +++ b/packages/schema/src/model.ts @@ -0,0 +1,106 @@ +export * as Model from "./model" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Provider } from "./provider" +import { statics } from "./schema" + +export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export type ID = typeof ID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +export const Ref = Schema.Struct({ + id: ID, + providerID: Provider.ID, + variant: VariantID.pipe(optional), +}).annotate({ identifier: "Model.Ref" }) +export interface Ref extends Schema.Schema.Type {} + +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +export interface Capabilities extends Schema.Schema.Type {} +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + input: Schema.Array(Schema.String), + output: Schema.Array(Schema.String), +}).annotate({ identifier: "Model.Capabilities" }) + +export interface Cost extends Schema.Schema.Type {} +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}).annotate({ identifier: "Model.Cost" }) + +export const Api = Schema.Union([ + Schema.Struct({ + id: ID, + ...Provider.AISDK.fields, + }), + Schema.Struct({ + id: ID, + ...Provider.Native.fields, + }), +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Model.Api" }) +export type Api = typeof Api.Type + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + providerID: Provider.ID, + family: Family.pipe(optional), + name: Schema.String, + api: Api, + capabilities: Capabilities, + request: Schema.Struct({ + ...Provider.Request.fields, + variant: Schema.String.pipe(optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...Provider.Request.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: Schema.Finite, + }), + cost: Schema.Array(Cost), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + enabled: Schema.Boolean, + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(optional), + output: Schema.Int, + }), +}) + .annotate({ identifier: "ModelV2.Info" }) + .pipe( + statics((schema) => ({ + empty: (providerID: Provider.ID, modelID: ID) => + schema.make({ + id: modelID, + providerID, + name: modelID, + api: { id: modelID, type: "native", settings: {} }, + capabilities: { tools: false, input: [], output: [] }, + request: { headers: {}, body: {} }, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 0, output: 0 }, + }), + })), + ) diff --git a/packages/schema/src/models-dev.ts b/packages/schema/src/models-dev.ts new file mode 100644 index 00000000000..4432bc4591e --- /dev/null +++ b/packages/schema/src/models-dev.ts @@ -0,0 +1,9 @@ +export * as ModelsDev from "./models-dev" + +import { define, inventory } from "./event" + +const Refreshed = define({ + type: "models-dev.refreshed", + schema: {}, +}) +export const Event = { Refreshed, Definitions: inventory(Refreshed) } diff --git a/packages/schema/src/permission-saved.ts b/packages/schema/src/permission-saved.ts new file mode 100644 index 00000000000..969a6dc25ca --- /dev/null +++ b/packages/schema/src/permission-saved.ts @@ -0,0 +1,20 @@ +export * as PermissionSaved from "./permission-saved" + +import { Schema } from "effect" +import { ascending } from "./identifier" +import { ProjectID } from "./project-id" +import { statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("PermissionSaved.ID"), + statics((schema) => ({ create: () => schema.make("psv_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + projectID: ProjectID, + action: Schema.String, + resource: Schema.String, +}).annotate({ identifier: "PermissionSaved.Info" }) +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/permission-v1.ts b/packages/schema/src/permission-v1.ts new file mode 100644 index 00000000000..558fec83dae --- /dev/null +++ b/packages/schema/src/permission-v1.ts @@ -0,0 +1 @@ +export * from "./v1/permission" diff --git a/packages/schema/src/permission.ts b/packages/schema/src/permission.ts new file mode 100644 index 00000000000..25d776284fd --- /dev/null +++ b/packages/schema/src/permission.ts @@ -0,0 +1,65 @@ +export * as Permission from "./permission" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" +import { statics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionV2.ID"), + statics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Source = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("tool"), + messageID: Schema.String, + callID: Schema.String, + }), +]).annotate({ identifier: "PermissionV2.Source" }) +export type Source = typeof Source.Type + +const RequestFields = { + sessionID: SessionID, + action: Schema.String, + resources: Schema.Array(Schema.String), + save: Schema.Array(Schema.String).pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), + source: Source.pipe(optional), +} + +export const Request = Schema.Struct({ + id: ID, + ...RequestFields, +}).annotate({ identifier: "PermissionV2.Request" }) +export interface Request extends Schema.Schema.Type {} + +export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) +export type Reply = typeof Reply.Type + +const Asked = define({ type: "permission.v2.asked", schema: Request.fields }) +const Replied = define({ + type: "permission.v2.replied", + schema: { + sessionID: SessionID, + requestID: ID, + reply: Reply, + }, +}) +export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } + +export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" }) +export type Effect = typeof Effect.Type + +export interface Rule extends Schema.Schema.Type {} +export const Rule = Schema.Struct({ + action: Schema.String, + resource: Schema.String, + effect: Effect, +}).annotate({ identifier: "PermissionV2.Rule" }) + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) +export type Ruleset = typeof Ruleset.Type diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts new file mode 100644 index 00000000000..c23f79582e3 --- /dev/null +++ b/packages/schema/src/plugin.ts @@ -0,0 +1,13 @@ +export * as Plugin from "./plugin" + +import { Schema } from "effect" +import { define, inventory } from "./event" + +export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export type ID = typeof ID.Type + +const Added = define({ + type: "plugin.added", + schema: { id: ID }, +}) +export const Event = { Added, Definitions: inventory(Added) } diff --git a/packages/schema/src/project-copy.ts b/packages/schema/src/project-copy.ts new file mode 100644 index 00000000000..850b87bcb35 --- /dev/null +++ b/packages/schema/src/project-copy.ts @@ -0,0 +1,30 @@ +export * as ProjectCopy from "./project-copy" + +import { Schema } from "effect" +import { optional } from "./schema" +import { ProjectID } from "./project-id" +import { AbsolutePath } from "./schema" + +export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) +export type StrategyID = typeof StrategyID.Type + +export const CreateInput = Schema.Struct({ + projectID: ProjectID, + strategy: StrategyID, + sourceDirectory: AbsolutePath, + directory: AbsolutePath, + name: optional(Schema.String), +}).annotate({ identifier: "ProjectCopy.CreateInput" }) +export interface CreateInput extends Schema.Schema.Type {} + +export const RemoveInput = Schema.Struct({ + projectID: ProjectID, + directory: AbsolutePath, + force: Schema.Boolean, +}).annotate({ identifier: "ProjectCopy.RemoveInput" }) +export interface RemoveInput extends Schema.Schema.Type {} + +export const Copy = Schema.Struct({ + directory: AbsolutePath, +}).annotate({ identifier: "ProjectCopy.Copy" }) +export interface Copy extends Schema.Schema.Type {} diff --git a/packages/schema/src/project-directories.ts b/packages/schema/src/project-directories.ts new file mode 100644 index 00000000000..e6cbde86499 --- /dev/null +++ b/packages/schema/src/project-directories.ts @@ -0,0 +1,10 @@ +export * as ProjectDirectories from "./project-directories" + +import { define, inventory } from "./event" +import { Project } from "./project" + +const Updated = define({ + type: "project.directories.updated", + schema: { projectID: Project.ID }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/project-id.ts b/packages/schema/src/project-id.ts new file mode 100644 index 00000000000..8d2f0615174 --- /dev/null +++ b/packages/schema/src/project-id.ts @@ -0,0 +1,8 @@ +import { Schema } from "effect" +import { statics } from "./schema" + +export const ProjectID = Schema.String.pipe( + Schema.brand("Project.ID"), + statics((schema) => ({ global: schema.make("global") })), +) +export type ProjectID = typeof ProjectID.Type diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts new file mode 100644 index 00000000000..7e73530a6d5 --- /dev/null +++ b/packages/schema/src/project.ts @@ -0,0 +1,44 @@ +export * as Project from "./project" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { NonNegativeInt, optional } from "./schema" +import { ProjectID } from "./project-id" + +export const ID = ProjectID +export type ID = typeof ID.Type + +export const Vcs = Schema.Literal("git").annotate({ identifier: "Project.Vcs" }) +export const Icon = Schema.Struct({ + url: optional(Schema.String), + override: optional(Schema.String), + color: optional(Schema.String), +}).annotate({ identifier: "Project.Icon" }) +export interface Icon extends Schema.Schema.Type {} +export const Commands = Schema.Struct({ + start: optional( + Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), + ), +}).annotate({ identifier: "Project.Commands" }) +export interface Commands extends Schema.Schema.Type {} +export const Time = Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + initialized: optional(NonNegativeInt), +}).annotate({ identifier: "Project.Time" }) +export interface Time extends Schema.Schema.Type {} + +export const Info = Schema.Struct({ + id: ID, + worktree: Schema.String, + vcs: optional(Vcs), + name: optional(Schema.String), + icon: optional(Icon), + commands: optional(Commands), + time: Time, + sandboxes: Schema.Array(Schema.String), +}).annotate({ identifier: "Project" }) +export interface Info extends Schema.Schema.Type {} + +const Updated = define({ type: "project.updated", schema: Info.fields }) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/prompt-input.ts b/packages/schema/src/prompt-input.ts new file mode 100644 index 00000000000..f2a0d460dfd --- /dev/null +++ b/packages/schema/src/prompt-input.ts @@ -0,0 +1,26 @@ +export * as PromptInput from "./prompt-input" + +import { Schema } from "effect" +import { AgentAttachment, Source } from "./prompt" +import { optional, statics } from "./schema" + +export interface FileAttachment extends Schema.Schema.Type {} +export const FileAttachment = Schema.Struct({ + uri: Schema.String, + name: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + source: Source.pipe(optional), +}) + .annotate({ identifier: "PromptInput.FileAttachment" }) + .pipe( + statics((schema) => ({ + create: (input: FileAttachment) => schema.make(input), + })), + ) + +export interface Prompt extends Schema.Schema.Type {} +export const Prompt = Schema.Struct({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(optional), + agents: Schema.Array(AgentAttachment).pipe(optional), +}).annotate({ identifier: "PromptInput" }) diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts new file mode 100644 index 00000000000..376700cc7a6 --- /dev/null +++ b/packages/schema/src/prompt.ts @@ -0,0 +1,57 @@ +import { Schema } from "effect" +import { optional } from "./schema" +import { statics } from "./schema" + +export interface Source extends Schema.Schema.Type {} +export const Source = Schema.Struct({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, +}).annotate({ identifier: "Prompt.Source" }) + +export interface FileAttachment extends Schema.Schema.Type {} +export const FileAttachment = Schema.Struct({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + source: Source.pipe(optional), +}) + .annotate({ identifier: "Prompt.FileAttachment" }) + .pipe( + statics((schema) => ({ + create: (input: FileAttachment) => + schema.make({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }), + })), + ) + +export interface AgentAttachment extends Schema.Schema.Type {} +export const AgentAttachment = Schema.Struct({ + name: Schema.String, + source: Source.pipe(optional), +}).annotate({ identifier: "Prompt.AgentAttachment" }) + +export interface Prompt extends Schema.Schema.Type {} +export const Prompt = Schema.Struct({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(optional), + agents: Schema.Array(AgentAttachment).pipe(optional), +}) + .annotate({ identifier: "Prompt" }) + .pipe( + statics((schema) => ({ + equivalence: Schema.toEquivalence(schema), + fromUserMessage: (input: Pick) => + schema.make({ + text: input.text, + ...(input.files === undefined ? {} : { files: input.files }), + ...(input.agents === undefined ? {} : { agents: input.agents }), + }), + })), + ) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts new file mode 100644 index 00000000000..51ff4b37913 --- /dev/null +++ b/packages/schema/src/provider.ts @@ -0,0 +1,72 @@ +export * as Provider from "./provider" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Integration } from "./integration" +import { statics } from "./schema" + +export const ID = Schema.String.pipe( + Schema.brand("ProviderV2.ID"), + statics((schema) => ({ + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ID = typeof ID.Type + +export interface AISDK extends Schema.Schema.Type {} +export const AISDK = Schema.Struct({ + type: Schema.Literal("aisdk"), + package: Schema.String, + url: Schema.String.pipe(optional), + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), +}).annotate({ identifier: "Provider.AISDK" }) + +export interface Native extends Schema.Schema.Type {} +export const Native = Schema.Struct({ + type: Schema.Literal("native"), + url: Schema.String.pipe(optional), + settings: Schema.Record(Schema.String, Schema.Unknown), +}).annotate({ identifier: "Provider.Native" }) + +export const Api = Schema.Union([AISDK, Native]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Provider.Api" }) +export type Api = typeof Api.Type + +export interface Request extends Schema.Schema.Type {} +export const Request = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Json), +}).annotate({ identifier: "Provider.Request" }) + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + integrationID: Integration.ID.pipe(optional), + name: Schema.String, + disabled: Schema.Boolean.pipe(optional), + api: Api, + request: Request, +}) + .annotate({ identifier: "ProviderV2.Info" }) + .pipe( + statics((schema) => ({ + empty: (id: ID) => + schema.make({ + id, + name: id, + api: { type: "native", settings: {} }, + request: { headers: {}, body: {} }, + }), + })), + ) diff --git a/packages/schema/src/pty-ticket.ts b/packages/schema/src/pty-ticket.ts new file mode 100644 index 00000000000..9b258575a90 --- /dev/null +++ b/packages/schema/src/pty-ticket.ts @@ -0,0 +1,10 @@ +export * as PtyTicket from "./pty-ticket" + +import { Schema } from "effect" +import { PositiveInt } from "./schema" + +export const ConnectToken = Schema.Struct({ + ticket: Schema.String, + expires_in: PositiveInt, +}).annotate({ identifier: "PtyTicket.ConnectToken" }) +export interface ConnectToken extends Schema.Schema.Type {} diff --git a/packages/schema/src/pty.ts b/packages/schema/src/pty.ts new file mode 100644 index 00000000000..1ed58393e93 --- /dev/null +++ b/packages/schema/src/pty.ts @@ -0,0 +1,60 @@ +export * as Pty from "./pty" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" // kilocode_change +import { NonNegativeInt, PositiveInt, statics } from "./schema" + +const IDSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) + +export const ID = IDSchema.pipe( + statics((schema: typeof IDSchema) => { + const create = () => schema.make("pty_" + ascending()) + return { + create, + ascending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + title: Schema.String, + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.String, + status: Schema.Literals(["running", "exited"]), + pid: NonNegativeInt, + exitCode: optional(NonNegativeInt), + sessionID: optional(Schema.NullOr(SessionID)), // kilocode_change - Kilo associates PTYs with sessions +}).annotate({ identifier: "Pty" }) +export interface Info extends Schema.Schema.Type {} + +const Created = define({ type: "pty.created", schema: { info: Info } }) +const Updated = define({ type: "pty.updated", schema: { info: Info } }) +const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegativeInt } }) +const Deleted = define({ type: "pty.deleted", schema: { id: ID } }) +export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) } + +export const CreateInput = Schema.Struct({ + command: optional(Schema.String), + args: optional(Schema.Array(Schema.String)), + cwd: optional(Schema.String), + title: optional(Schema.String), + env: optional(Schema.Record(Schema.String, Schema.String)), +}) +export interface CreateInput extends Schema.Schema.Type {} + +export const UpdateInput = Schema.Struct({ + title: optional(Schema.String), + size: optional( + Schema.Struct({ + rows: PositiveInt, + cols: PositiveInt, + }), + ), +}) +export interface UpdateInput extends Schema.Schema.Type {} diff --git a/packages/schema/src/question-v1.ts b/packages/schema/src/question-v1.ts new file mode 100644 index 00000000000..4bb237244ef --- /dev/null +++ b/packages/schema/src/question-v1.ts @@ -0,0 +1 @@ +export * from "./v1/question" diff --git a/packages/schema/src/question.ts b/packages/schema/src/question.ts new file mode 100644 index 00000000000..aba5ebfbaf2 --- /dev/null +++ b/packages/schema/src/question.ts @@ -0,0 +1,86 @@ +export * as Question from "./question" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { SessionID } from "./session-id" +import { statics } from "./schema" + +export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( + Schema.brand("QuestionV2.ID"), + statics((schema) => { + const create = () => schema.make("que_" + ascending()) + return { + create, + ascending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type ID = typeof ID.Type + +export const Option = Schema.Struct({ + label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), + description: Schema.String.annotate({ description: "Explanation of choice" }), +}).annotate({ identifier: "QuestionV2.Option" }) +export interface Option extends Schema.Schema.Type {} + +const base = { + question: Schema.String.annotate({ description: "Complete question" }), + header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), + options: Schema.Array(Option).annotate({ description: "Available choices" }), + multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }), +} + +export const Info = Schema.Struct({ + ...base, + custom: Schema.Boolean.pipe(optional).annotate({ + description: "Allow typing a custom answer (default: true)", + }), +}).annotate({ identifier: "QuestionV2.Info" }) +export interface Info extends Schema.Schema.Type {} + +export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" }) +export interface Prompt extends Schema.Schema.Type {} + +export const Tool = Schema.Struct({ + messageID: Schema.String, + callID: Schema.String, +}).annotate({ identifier: "QuestionV2.Tool" }) +export interface Tool extends Schema.Schema.Type {} + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), + tool: Tool.pipe(optional), +}).annotate({ identifier: "QuestionV2.Request" }) +export interface Request extends Schema.Schema.Type {} + +export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" }) +export type Answer = typeof Answer.Type + +export const Reply = Schema.Struct({ + answers: Schema.Array(Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}).annotate({ identifier: "QuestionV2.Reply" }) +export interface Reply extends Schema.Schema.Type {} + +const Asked = define({ type: "question.v2.asked", schema: Request.fields }) +const Replied = define({ + type: "question.v2.replied", + schema: { + sessionID: SessionID, + requestID: ID, + answers: Schema.Array(Answer), + }, +}) +const Rejected = define({ + type: "question.v2.rejected", + schema: { + sessionID: SessionID, + requestID: ID, + }, +}) +export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) } diff --git a/packages/schema/src/reference.ts b/packages/schema/src/reference.ts new file mode 100644 index 00000000000..84b623a2742 --- /dev/null +++ b/packages/schema/src/reference.ts @@ -0,0 +1,39 @@ +export * as Reference from "./reference" + +import { Schema } from "effect" +import { optional } from "./schema" +import { define, inventory } from "./event" +import { AbsolutePath } from "./schema" + +const Updated = define({ type: "reference.updated", schema: {} }) +export const Event = { Updated, Definitions: inventory(Updated) } + +export interface LocalSource extends Schema.Schema.Type {} +export const LocalSource = Schema.Struct({ + type: Schema.Literal("local"), + path: AbsolutePath, + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), +}).annotate({ identifier: "Reference.LocalSource" }) + +export interface GitSource extends Schema.Schema.Type {} +export const GitSource = Schema.Struct({ + type: Schema.Literal("git"), + repository: Schema.String, + branch: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), +}).annotate({ identifier: "Reference.GitSource" }) + +export const Source = Schema.Union([LocalSource, GitSource]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Reference.Source" }) +export type Source = typeof Source.Type + +export class Info extends Schema.Class("Reference.Info")({ + name: Schema.String, + path: AbsolutePath, + description: Schema.String.pipe(optional), + hidden: Schema.Boolean.pipe(optional), + source: Source, +}) {} diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts new file mode 100644 index 00000000000..05222d53981 --- /dev/null +++ b/packages/schema/src/revert.ts @@ -0,0 +1,24 @@ +export * as Revert from "./revert" + +import { Schema } from "effect" +import { optional } from "./schema" +import { NonNegativeInt, RelativePath } from "./schema" +import { SessionMessage } from "./session-message" + +export const FileDiff = Schema.Struct({ + path: RelativePath, + status: Schema.Literals(["added", "modified", "deleted"]), + additions: NonNegativeInt, + deletions: NonNegativeInt, + patch: Schema.String, +}).annotate({ identifier: "File.Diff" }) +export interface FileDiff extends Schema.Schema.Type {} + +export const State = Schema.Struct({ + messageID: SessionMessage.ID, + partID: Schema.String.pipe(optional), + snapshot: Schema.String.pipe(optional), + diff: Schema.String.pipe(optional), + files: Schema.Array(FileDiff).pipe(optional), +}).annotate({ identifier: "Revert.State" }) +export interface State extends Schema.Schema.Type {} diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts new file mode 100644 index 00000000000..d19a39b9708 --- /dev/null +++ b/packages/schema/src/schema.ts @@ -0,0 +1,30 @@ +import { DateTime, Option, Schema, SchemaGetter } from "effect" + +export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) +export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + +export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) +export type RelativePath = typeof RelativePath.Type + +export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) +export type AbsolutePath = typeof AbsolutePath.Type + +export const optional = (schema: S) => + Schema.optionalKey(schema).pipe( + Schema.decodeTo(Schema.optional(Schema.toType(schema)), { + decode: SchemaGetter.passthrough({ strict: false }), + encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), + }), + ) + +export const statics = + >(methods: (schema: S) => M) => + (schema: S): S & M => + Object.assign(schema, methods(schema)) + +export const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) diff --git a/packages/schema/src/server-event.ts b/packages/schema/src/server-event.ts new file mode 100644 index 00000000000..05bd8b0d3e0 --- /dev/null +++ b/packages/schema/src/server-event.ts @@ -0,0 +1,11 @@ +export * as ServerEvent from "./server-event" + +import { Event } from "./event" + +export const Connected = Event.define({ type: "server.connected", schema: {} }) +export const Disposed = Event.define({ type: "global.disposed", schema: {} }) +// kilocode_change start - emitted (via GlobalBus) when config updates without a full dispose +export const ConfigUpdated = Event.define({ type: "global.config.updated", schema: {} }) +// kilocode_change end + +export const Definitions = Event.inventory(Connected, Disposed, ConfigUpdated) // kilocode_change diff --git a/packages/schema/src/session-compaction-event.ts b/packages/schema/src/session-compaction-event.ts new file mode 100644 index 00000000000..ed1169ea675 --- /dev/null +++ b/packages/schema/src/session-compaction-event.ts @@ -0,0 +1,13 @@ +export * as SessionCompactionEvent from "./session-compaction-event" + +import { Event } from "./event" +import { SessionID } from "./session-id" + +export const Compacted = Event.define({ + type: "session.compacted", + schema: { + sessionID: SessionID, + }, +}) + +export const Definitions = Event.inventory(Compacted) diff --git a/packages/schema/src/session-delivery.ts b/packages/schema/src/session-delivery.ts new file mode 100644 index 00000000000..9b678dabf9f --- /dev/null +++ b/packages/schema/src/session-delivery.ts @@ -0,0 +1,6 @@ +export * as SessionDelivery from "./session-delivery" + +import { Schema } from "effect" + +export const Delivery = Schema.Literals(["steer", "queue"]) +export type Delivery = typeof Delivery.Type diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts new file mode 100644 index 00000000000..db044c91f77 --- /dev/null +++ b/packages/schema/src/session-event.ts @@ -0,0 +1,522 @@ +export * as SessionEvent from "./session-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" +import { ProviderMetadata, ToolContent } from "./llm" +import { Delivery } from "./session-delivery" +import { Model } from "./model" +import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema" +import { FileAttachment, Prompt } from "./prompt" +import { SessionID } from "./session-id" +import { Location } from "./location" +import { SessionMessage } from "./session-message" +import { Revert } from "./revert" + +export { FileAttachment } + +export const Source = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + text: Schema.String, +}).annotate({ + identifier: "session.next.event.source", +}) +export interface Source extends Schema.Schema.Type {} + +const Base = { + timestamp: DateTimeUtcFromMillis, + sessionID: SessionID, +} +const PromptFields = { + ...Base, + messageID: SessionMessage.ID, + prompt: Prompt, + delivery: Delivery, +} + +const options = { + durable: { + aggregate: "sessionID", + version: 1, + }, +} as const +const stepSettlementOptions = { + durable: { + aggregate: "sessionID", + version: 2, + }, +} as const + +export const UnknownError = SessionMessage.UnknownError +export type UnknownError = SessionMessage.UnknownError + +export const AgentSwitched = Event.define({ + type: "session.next.agent.switched", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + agent: Schema.String, + }, +}) +export type AgentSwitched = typeof AgentSwitched.Type + +export const ModelSwitched = Event.define({ + type: "session.next.model.switched", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + model: Model.Ref, + }, +}) +export type ModelSwitched = typeof ModelSwitched.Type + +export const Moved = Event.define({ + type: "session.next.moved", + ...options, + schema: { + ...Base, + location: Location.Ref, + subdirectory: RelativePath.pipe(optional), + }, +}) +export type Moved = typeof Moved.Type + +export const Prompted = Event.define({ + type: "session.next.prompted", + ...options, + schema: PromptFields, +}) +export type Prompted = typeof Prompted.Type + +export const PromptAdmitted = Event.define({ + type: "session.next.prompt.admitted", + ...options, + schema: PromptFields, +}) +export type PromptAdmitted = typeof PromptAdmitted.Type + +export const ContextUpdated = Event.define({ + type: "session.next.context.updated", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + text: Schema.String, + }, +}) +export type ContextUpdated = typeof ContextUpdated.Type + +export const Synthetic = Event.define({ + type: "session.next.synthetic", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + text: Schema.String, + }, +}) +export type Synthetic = typeof Synthetic.Type + +export namespace Shell { + export const Started = Event.define({ + type: "session.next.shell.started", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + callID: Schema.String, + command: Schema.String, + }, + }) + export type Started = typeof Started.Type + + export const Ended = Event.define({ + type: "session.next.shell.ended", + ...options, + schema: { + ...Base, + callID: Schema.String, + output: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Step { + export const Started = Event.define({ + type: "session.next.step.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + agent: Schema.String, + model: Model.Ref, + snapshot: Schema.String.pipe(optional), + }, + }) + export type Started = typeof Started.Type + + export const Ended = Event.define({ + type: "session.next.step.ended", + ...stepSettlementOptions, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + finish: Schema.String, + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + snapshot: Schema.String.pipe(optional), + files: Schema.Array(RelativePath).pipe(optional), + }, + }) + export type Ended = typeof Ended.Type + + export const Failed = Event.define({ + type: "session.next.step.failed", + ...stepSettlementOptions, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + error: UnknownError, + }, + }) + export type Failed = typeof Failed.Type +} + +export namespace Text { + export const Started = Event.define({ + type: "session.next.text.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + textID: Schema.String, + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Text.Ended is the replayable full-value boundary. + export const Delta = Event.define({ + type: "session.next.text.delta", + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + textID: Schema.String, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.text.ended", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + textID: Schema.String, + text: Schema.String, + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Reasoning { + export const Started = Event.define({ + type: "session.next.reasoning.started", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + reasoningID: Schema.String, + providerMetadata: ProviderMetadata.pipe(optional), + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. + export const Delta = Event.define({ + type: "session.next.reasoning.delta", + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + reasoningID: Schema.String, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.reasoning.ended", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + reasoningID: Schema.String, + text: Schema.String, + providerMetadata: ProviderMetadata.pipe(optional), + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace Tool { + const ToolBase = { + ...Base, + assistantMessageID: SessionMessage.ID, + callID: Schema.String, + } + + export namespace Input { + export const Started = Event.define({ + type: "session.next.tool.input.started", + ...options, + schema: { + ...ToolBase, + name: Schema.String, + }, + }) + export type Started = typeof Started.Type + + // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. + export const Delta = Event.define({ + type: "session.next.tool.input.delta", + schema: { + ...ToolBase, + delta: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.tool.input.ended", + ...options, + schema: { + ...ToolBase, + text: Schema.String, + }, + }) + export type Ended = typeof Ended.Type + } + + export const Called = Event.define({ + type: "session.next.tool.called", + ...options, + schema: { + ...ToolBase, + tool: Schema.String, + input: Schema.Record(Schema.String, Schema.Unknown), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + }), + }, + }) + export type Called = typeof Called.Type + + /** + * Replayable bounded running-tool state. Tools should checkpoint semantic + * transitions or at a bounded cadence, not persist every stdout/stderr chunk. + */ + export const Progress = Event.define({ + type: "session.next.tool.progress", + ...options, + schema: { + ...ToolBase, + structured: Schema.Record(Schema.String, Schema.Unknown), + content: Schema.Array(ToolContent), + }, + }) + export type Progress = typeof Progress.Type + + export const Success = Event.define({ + type: "session.next.tool.success", + ...options, + schema: { + ...ToolBase, + structured: Schema.Record(Schema.String, Schema.Unknown), + content: Schema.Array(ToolContent), + outputPaths: Schema.Array(Schema.String).pipe(optional), + result: Schema.Unknown.pipe(optional), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + }), + }, + }) + export type Success = typeof Success.Type + + export const Failed = Event.define({ + type: "session.next.tool.failed", + ...options, + schema: { + ...ToolBase, + error: UnknownError, + result: Schema.Unknown.pipe(optional), + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + }), + }, + }) + export type Failed = typeof Failed.Type +} + +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: Schema.Finite.pipe(optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(optional), + responseBody: Schema.String.pipe(optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(optional), +}).annotate({ + identifier: "session.next.retry_error", +}) +export interface RetryError extends Schema.Schema.Type {} + +export const Retried = Event.define({ + type: "session.next.retried", + ...options, + schema: { + ...Base, + attempt: Schema.Finite, + error: RetryError, + }, +}) +export type Retried = typeof Retried.Type + +export namespace Compaction { + export const Started = Event.define({ + type: "session.next.compaction.started", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), + }, + }) + export type Started = typeof Started.Type + + export const Delta = Event.define({ + type: "session.next.compaction.delta", + schema: { + ...Base, + messageID: SessionMessage.ID, + text: Schema.String, + }, + }) + export type Delta = typeof Delta.Type + + export const Ended = Event.define({ + type: "session.next.compaction.ended", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + reason: Started.data.fields.reason, + text: Schema.String, + recent: Schema.String, + include: Schema.String.pipe(Schema.optional), // kilocode_change - released v1 readers recognize this compatibility field + }, + }) + export type Ended = typeof Ended.Type +} + +export namespace RevertEvent { + export const Staged = Event.define({ + type: "session.next.revert.staged", + ...options, + schema: { ...Base, revert: Revert.State }, + }) + export const Cleared = Event.define({ type: "session.next.revert.cleared", ...options, schema: Base }) + export const Committed = Event.define({ + type: "session.next.revert.committed", + ...options, + schema: { ...Base, messageID: SessionMessage.ID }, + }) +} + +export const DurableDefinitions = Event.inventory( + AgentSwitched, + ModelSwitched, + Moved, + Prompted, + PromptAdmitted, + ContextUpdated, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Ended, + Tool.Input.Started, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Reasoning.Started, + Reasoning.Ended, + Retried, + Compaction.Started, + Compaction.Ended, + RevertEvent.Staged, + RevertEvent.Cleared, + RevertEvent.Committed, +) + +export const Definitions = Event.inventory( + AgentSwitched, + ModelSwitched, + Moved, + Prompted, + PromptAdmitted, + ContextUpdated, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Delta, + Text.Ended, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Retried, + Compaction.Started, + Compaction.Delta, + Compaction.Ended, + RevertEvent.Staged, + RevertEvent.Cleared, + RevertEvent.Committed, +) + +export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "SessionDurableEvent" }) +export type DurableEvent = typeof Durable.Type + +export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) +export type Event = typeof All.Type +export type Type = Event["type"] diff --git a/packages/schema/src/session-id.ts b/packages/schema/src/session-id.ts new file mode 100644 index 00000000000..3603ebe703b --- /dev/null +++ b/packages/schema/src/session-id.ts @@ -0,0 +1,15 @@ +import { Schema } from "effect" +import { descending } from "./identifier" +import { statics } from "./schema" + +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( + Schema.brand("SessionID"), + statics((schema) => { + const create = () => schema.make("ses_" + descending()) + return { + create, + descending: (id?: string) => (id === undefined ? create() : schema.make(id)), + } + }), +) +export type SessionID = typeof SessionID.Type diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts new file mode 100644 index 00000000000..40babac105f --- /dev/null +++ b/packages/schema/src/session-input.ts @@ -0,0 +1,23 @@ +export * as SessionInput from "./session-input" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Prompt } from "./prompt" +import { DateTimeUtcFromMillis, NonNegativeInt } from "./schema" +import { SessionDelivery } from "./session-delivery" +import { SessionID } from "./session-id" +import { SessionMessage } from "./session-message" + +export const Delivery = SessionDelivery.Delivery +export type Delivery = SessionDelivery.Delivery + +export interface Admitted extends Schema.Schema.Type {} +export const Admitted = Schema.Struct({ + admittedSeq: NonNegativeInt, + id: SessionMessage.ID, + sessionID: SessionID, + prompt: Prompt, + delivery: Delivery, + timeCreated: DateTimeUtcFromMillis, + promotedSeq: NonNegativeInt.pipe(optional), +}).annotate({ identifier: "SessionInput.Admitted" }) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts new file mode 100644 index 00000000000..58ff532063a --- /dev/null +++ b/packages/schema/src/session-message.ts @@ -0,0 +1,213 @@ +export * as SessionMessage from "./session-message" + +import { Schema } from "effect" +import { optional } from "./schema" +import { ProviderMetadata, ToolContent } from "./llm" +import { Model } from "./model" +import { FileAttachment, Prompt } from "./prompt" +import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema" +import { SessionID } from "./session-id" +import { ascending } from "./identifier" + +export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( + Schema.brand("Session.Message.ID"), + statics((schema) => ({ create: () => schema.make("msg_" + ascending()) })), +) +export type ID = typeof ID.Type + +export interface UnknownError extends Schema.Schema.Type {} +export const UnknownError = Schema.Struct({ + type: Schema.Literal("unknown"), + message: Schema.String, +}).annotate({ identifier: "Session.Error.Unknown" }) + +const Base = { + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), + time: Schema.Struct({ created: DateTimeUtcFromMillis }), +} + +export interface AgentSwitched extends Schema.Schema.Type {} +export const AgentSwitched = Schema.Struct({ + ...Base, + type: Schema.Literal("agent-switched"), + agent: Schema.String, +}).annotate({ identifier: "Session.Message.AgentSwitched" }) + +export interface ModelSwitched extends Schema.Schema.Type {} +export const ModelSwitched = Schema.Struct({ + ...Base, + type: Schema.Literal("model-switched"), + model: Model.Ref, +}).annotate({ identifier: "Session.Message.ModelSwitched" }) + +export interface User extends Schema.Schema.Type {} +export const User = Schema.Struct({ + ...Base, + text: Prompt.fields.text, + files: Prompt.fields.files, + agents: Prompt.fields.agents, + type: Schema.Literal("user"), +}).annotate({ identifier: "Session.Message.User" }) + +export interface Synthetic extends Schema.Schema.Type {} +export const Synthetic = Schema.Struct({ + ...Base, + sessionID: SessionID, + text: Schema.String, + type: Schema.Literal("synthetic"), +}).annotate({ identifier: "Session.Message.Synthetic" }) + +export interface System extends Schema.Schema.Type {} +export const System = Schema.Struct({ + ...Base, + type: Schema.Literal("system"), + text: Schema.String, +}).annotate({ identifier: "Session.Message.System" }) + +export interface Shell extends Schema.Schema.Type {} +export const Shell = Schema.Struct({ + ...Base, + type: Schema.Literal("shell"), + callID: Schema.String, + command: Schema.String, + output: Schema.String, + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }), +}).annotate({ identifier: "Session.Message.Shell" }) + +export interface ToolStatePending extends Schema.Schema.Type {} +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.String, +}).annotate({ identifier: "Session.Message.ToolState.Pending" }) + +export interface ToolStateRunning extends Schema.Schema.Type {} +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Unknown), + structured: Schema.Record(Schema.String, Schema.Unknown), + content: ToolContent.pipe(Schema.Array), +}).annotate({ identifier: "Session.Message.ToolState.Running" }) + +export interface ToolStateCompleted extends Schema.Schema.Type {} +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Unknown), + attachments: FileAttachment.pipe(Schema.Array, optional), + content: ToolContent.pipe(Schema.Array), + outputPaths: Schema.Array(Schema.String).pipe(optional), + structured: Schema.Record(Schema.String, Schema.Unknown), + result: Schema.Unknown.pipe(optional), +}).annotate({ identifier: "Session.Message.ToolState.Completed" }) + +export interface ToolStateError extends Schema.Schema.Type {} +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Unknown), + content: ToolContent.pipe(Schema.Array), + structured: Schema.Record(Schema.String, Schema.Unknown), + error: UnknownError, + result: Schema.Unknown.pipe(optional), +}).annotate({ identifier: "Session.Message.ToolState.Error" }) + +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export interface AssistantTool extends Schema.Schema.Type {} +export const AssistantTool = Schema.Struct({ + type: Schema.Literal("tool"), + id: Schema.String, + name: Schema.String, + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: ProviderMetadata.pipe(optional), + resultMetadata: ProviderMetadata.pipe(optional), + }).pipe(optional), + state: ToolState, + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + ran: DateTimeUtcFromMillis.pipe(optional), + completed: DateTimeUtcFromMillis.pipe(optional), + pruned: DateTimeUtcFromMillis.pipe(optional), + }), +}).annotate({ identifier: "Session.Message.Assistant.Tool" }) + +export interface AssistantText extends Schema.Schema.Type {} +export const AssistantText = Schema.Struct({ + type: Schema.Literal("text"), + id: Schema.String, + text: Schema.String, +}).annotate({ identifier: "Session.Message.Assistant.Text" }) + +export interface AssistantReasoning extends Schema.Schema.Type {} +export const AssistantReasoning = Schema.Struct({ + type: Schema.Literal("reasoning"), + id: Schema.String, + text: Schema.String, + providerMetadata: ProviderMetadata.pipe(optional), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }).pipe(optional), +}).annotate({ identifier: "Session.Message.Assistant.Reasoning" }) + +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) +export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool + +export interface Assistant extends Schema.Schema.Type {} +export const Assistant = Schema.Struct({ + ...Base, + type: Schema.Literal("assistant"), + agent: Schema.String, + model: Model.Ref, + content: AssistantContent.pipe(Schema.Array), + snapshot: Schema.Struct({ + start: Schema.String.pipe(optional), + end: Schema.String.pipe(optional), + files: Schema.Array(RelativePath).pipe(optional), + }).pipe(optional), + finish: Schema.String.pipe(optional), + cost: Schema.Finite.pipe(optional), + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }), + }).pipe(optional), + error: UnknownError.pipe(optional), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }), +}).annotate({ identifier: "Session.Message.Assistant" }) + +export interface Compaction extends Schema.Schema.Type {} +export const Compaction = Schema.Struct({ + type: Schema.Literal("compaction"), + reason: Schema.Literals(["auto", "manual"]), + summary: Schema.String, + recent: Schema.String, + ...Base, +}).annotate({ identifier: "Session.Message.Compaction" }) + +export const Message = Schema.Union([ + AgentSwitched, + ModelSwitched, + User, + Synthetic, + System, + Shell, + Assistant, + Compaction, +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Session.Message" }) +export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Shell | Assistant | Compaction +export type Type = Message["type"] diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts new file mode 100644 index 00000000000..475bd486cc0 --- /dev/null +++ b/packages/schema/src/session-status-event.ts @@ -0,0 +1,60 @@ +export * as SessionStatusEvent from "./session-status-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" +import { NonNegativeInt } from "./schema" +import { SessionID } from "./session-id" + +const QuestionID = Schema.String.check(Schema.isStartsWith("que")).pipe(Schema.brand("QuestionID")) // kilocode_change + +export const Info = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("idle"), + }), + Schema.Struct({ + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + message: Schema.String, + action: optional( + Schema.Struct({ + reason: Schema.String, + provider: Schema.String, + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: optional(Schema.String), + }), + ), + next: NonNegativeInt, + }), + Schema.Struct({ + type: Schema.Literal("busy"), + }), + // kilocode_change start - represent a session paused for an offline Kilo question + Schema.Struct({ + type: Schema.Literal("offline"), + requestID: QuestionID, + message: Schema.String, + }), + // kilocode_change end +]).annotate({ identifier: "SessionStatus" }) +export type Info = Schema.Schema.Type + +export const Status = Event.define({ + type: "session.status", + schema: { + sessionID: SessionID, + status: Info, + }, +}) + +// deprecated +export const Idle = Event.define({ + type: "session.idle", + schema: { + sessionID: SessionID, + }, +}) + +export const Definitions = Event.inventory(Status, Idle) diff --git a/packages/schema/src/session-todo.ts b/packages/schema/src/session-todo.ts new file mode 100644 index 00000000000..7f6a72bf000 --- /dev/null +++ b/packages/schema/src/session-todo.ts @@ -0,0 +1,25 @@ +export * as SessionTodo from "./session-todo" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { SessionID } from "./session-id" + +export const Info = Schema.Struct({ + content: Schema.String.annotate({ description: "Brief description of the task" }), + status: Schema.String.annotate({ + description: "Current status of the task: pending, in_progress, completed, cancelled", + }), + priority: Schema.String.annotate({ + description: "Priority level of the task: high, medium, low", + }), +}).annotate({ identifier: "Todo" }) +export interface Info extends Schema.Schema.Type {} + +const Updated = define({ + type: "todo.updated", + schema: { + sessionID: SessionID, + todos: Schema.Array(Info), + }, +}) +export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/src/session-v1.ts b/packages/schema/src/session-v1.ts new file mode 100644 index 00000000000..22455dd4125 --- /dev/null +++ b/packages/schema/src/session-v1.ts @@ -0,0 +1 @@ +export * from "./v1/session" diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts new file mode 100644 index 00000000000..937705eeb2f --- /dev/null +++ b/packages/schema/src/session.ts @@ -0,0 +1,51 @@ +export * as Session from "./session" + +import { Schema } from "effect" +import { Agent } from "./agent" +import { Location } from "./location" +import { Model } from "./model" +import { Project } from "./project" +import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema" +import { SessionEvent } from "./session-event" +import { SessionID } from "./session-id" +import { Revert } from "./revert" + +export const ID = SessionID +export type ID = SessionID + +export const Event = SessionEvent + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + id: ID, + parentID: ID.pipe(optional), + projectID: Project.ID, + agent: Agent.ID.pipe(optional), + model: Model.Ref.pipe(optional), + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + updated: DateTimeUtcFromMillis, + archived: DateTimeUtcFromMillis.pipe(optional), + }), + title: Schema.String, + location: Location.Ref, + subpath: RelativePath.pipe(optional), + revert: Revert.State.pipe(optional), +}).annotate({ identifier: "SessionV2.Info" }) + +export const ListAnchor = Schema.Struct({ + id: ID, + time: Schema.Finite, + direction: Schema.Literals(["previous", "next"]), +}).annotate({ identifier: "Session.ListAnchor" }) +export interface ListAnchor extends Schema.Schema.Type {} diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts new file mode 100644 index 00000000000..ec299180ed0 --- /dev/null +++ b/packages/schema/src/skill.ts @@ -0,0 +1,55 @@ +export * as Skill from "./skill" + +import { Schema } from "effect" +import { optional } from "./schema" +import { AbsolutePath } from "./schema" + +export interface DirectorySource extends Schema.Schema.Type {} +export const DirectorySource = Schema.Struct({ + type: Schema.Literal("directory"), + path: AbsolutePath, +}).annotate({ identifier: "SkillV2.DirectorySource" }) + +export interface UrlSource extends Schema.Schema.Type {} +export const UrlSource = Schema.Struct({ + type: Schema.Literal("url"), + url: Schema.String, +}).annotate({ identifier: "SkillV2.UrlSource" }) + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.String.pipe(optional), + slash: Schema.Boolean.pipe(optional), + location: AbsolutePath, + content: Schema.String, +}).annotate({ identifier: "SkillV2.Info" }) + +export interface EmbeddedSource extends Schema.Schema.Type {} +export const EmbeddedSource = Schema.Struct({ + type: Schema.Literal("embedded"), + skill: Schema.suspend(() => Info), +}).annotate({ identifier: "SkillV2.EmbeddedSource" }) + +export type Source = DirectorySource | UrlSource | EmbeddedSource +export const Source = Object.assign( + Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe( + Schema.toTaggedUnion("type"), + Schema.annotate({ identifier: "SkillV2.Source" }), + ), + { + equals: (a: Source, b: Source) => { + if (a.type !== b.type) return false + if (a.type === "directory" && b.type === "directory") return a.path === b.path + if (a.type === "url" && b.type === "url") return a.url === b.url + if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name + return false + }, + key: (source: Source) => + source.type === "directory" + ? `directory:${source.path}` + : source.type === "url" + ? `url:${source.url}` + : `embedded:${source.skill.name}`, + }, +) diff --git a/packages/schema/src/tui-event.ts b/packages/schema/src/tui-event.ts new file mode 100644 index 00000000000..800094e61e8 --- /dev/null +++ b/packages/schema/src/tui-event.ts @@ -0,0 +1,59 @@ +export * as TuiEvent from "./tui-event" + +import { Effect, Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" +import { PositiveInt } from "./schema" +import { SessionID } from "./session-id" + +const DEFAULT_TOAST_DURATION = 5000 + +export const PromptAppend = Event.define({ type: "tui.prompt.append", schema: { text: Schema.String } }) + +export const CommandExecute = Event.define({ + type: "tui.command.execute", + schema: { + command: Schema.Union([ + Schema.Literals([ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle", + ]), + Schema.String, + ]), + }, +}) + +export const ToastShow = Event.define({ + type: "tui.toast.show", + schema: { + title: optional(Schema.String), + message: Schema.String, + variant: Schema.Literals(["info", "success", "warning", "error"]), + duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ + description: "Duration in milliseconds", + }), + }, +}) + +export const SessionSelect = Event.define({ + type: "tui.session.select", + schema: { + sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), + }, +}) + +export const Definitions = Event.inventory(PromptAppend, CommandExecute, ToastShow, SessionSelect) diff --git a/packages/schema/src/v1/legacy-event.ts b/packages/schema/src/v1/legacy-event.ts new file mode 100644 index 00000000000..d87902dad57 --- /dev/null +++ b/packages/schema/src/v1/legacy-event.ts @@ -0,0 +1,18 @@ +export * as LegacyEvent from "./legacy-event" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { SessionID } from "../session-id" +import { SessionV1 } from "./session" + +export const CommandExecuted = define({ + type: "command.executed", + schema: { + name: Schema.String, + sessionID: SessionID, + arguments: Schema.String, + messageID: SessionV1.MessageID, + }, +}) + +export const Definitions = inventory(CommandExecuted) diff --git a/packages/schema/src/v1/permission.ts b/packages/schema/src/v1/permission.ts new file mode 100644 index 00000000000..1ed967c581d --- /dev/null +++ b/packages/schema/src/v1/permission.ts @@ -0,0 +1,71 @@ +export * as PermissionV1 from "./permission" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { ascending } from "../identifier" +import { Project } from "../project" +import { statics } from "../schema" +import { SessionID } from "../session-id" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + ascending()) })), +) +export type ID = typeof ID.Type + +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) +export type Action = typeof Action.Type + +export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action }).annotate({ + identifier: "PermissionRule", +}) +export type Rule = typeof Rule.Type + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) +export type Ruleset = typeof Ruleset.Type + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional(Schema.Struct({ messageID: Schema.String, callID: Schema.String })), +}).annotate({ identifier: "PermissionRequest" }) +export type Request = typeof Request.Type + +export const Reply = Schema.Literals(["once", "always", "reject"]) +export type Reply = typeof Reply.Type + +export const ReplyBody = Schema.Struct({ + reply: Reply, + message: Schema.optional(Schema.String), + // kilocode_change - set by clients when a human answered the prompt; the server refuses machine approvals of skill-shell batches + interactive: Schema.optional(Schema.Boolean), +}).annotate({ + identifier: "PermissionReplyBody", +}) +export type ReplyBody = typeof ReplyBody.Type + +export const Approval = Schema.Struct({ projectID: Project.ID, patterns: Schema.Array(Schema.String) }).annotate({ + identifier: "PermissionApproval", +}) +export type Approval = typeof Approval.Type + +export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(ID), ruleset: Ruleset }).annotate({ + identifier: "PermissionAskInput", +}) +export type AskInput = typeof AskInput.Type + +export const ReplyInput = Schema.Struct({ requestID: ID, ...ReplyBody.fields }).annotate({ + identifier: "PermissionReplyInput", +}) +export type ReplyInput = typeof ReplyInput.Type + +const Asked = define({ type: "permission.asked", schema: Request.fields }) +const Replied = define({ + type: "permission.replied", + schema: { sessionID: SessionID, requestID: ID, reply: Reply }, +}) +export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) } diff --git a/packages/schema/src/v1/question.ts b/packages/schema/src/v1/question.ts new file mode 100644 index 00000000000..99d3ee055a9 --- /dev/null +++ b/packages/schema/src/v1/question.ts @@ -0,0 +1,86 @@ +export * as QuestionV1 from "./question" + +import { Schema } from "effect" +import { define, inventory } from "../event" +import { ascending } from "../identifier" +import { statics } from "../schema" +import { SessionID } from "../session-id" +import { SessionV1 } from "./session" + +export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( + Schema.brand("QuestionID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })), +) + +export const Option = Schema.Struct({ + label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), + description: Schema.String.annotate({ description: "Explanation of choice" }), + // kilocode_change start - Kilo client localization and mode selection hints + labelKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the label; clients translate and still reply with `label`", + }), + descriptionKey: Schema.optional(Schema.String).annotate({ description: "Optional i18n key for the description" }), + mode: Schema.optional(Schema.String).annotate({ + description: "Optional agent/mode name to pre-select in the UI when this option is picked", + }), + // kilocode_change end +}).annotate({ identifier: "QuestionOption" }) + +const base = { + question: Schema.String.annotate({ description: "Complete question" }), + header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), + options: Schema.Array(Option).annotate({ description: "Available choices" }), + multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices" }), + // kilocode_change start - optional Kilo client localization keys + questionKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the question text; clients fall back to `question` when missing", + }), + headerKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the header; clients fall back to `header` when missing", + }), + // kilocode_change end +} + +export const Info = Schema.Struct({ + ...base, + custom: Schema.optional(Schema.Boolean).annotate({ description: "Allow typing a custom answer (default: true)" }), +}).annotate({ identifier: "QuestionInfo" }) +export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" }) +export const Tool = Schema.Struct({ messageID: SessionV1.MessageID, callID: Schema.String }).annotate({ + identifier: "QuestionTool", +}) +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionID, + questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), + blocking: Schema.optional(Schema.Boolean).annotate({ + description: "Whether this question blocks prompt input (default: true)", + }), // kilocode_change + tool: Schema.optional(Tool), +}).annotate({ identifier: "QuestionRequest" }) +export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" }) +export const Reply = Schema.Struct({ + answers: Schema.Array(Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}).annotate({ identifier: "QuestionReply" }) +export const Replied = Schema.Struct({ + sessionID: SessionID, + requestID: ID, + answers: Schema.Array(Answer), +}).annotate({ + identifier: "QuestionReplied", +}) +export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: ID }).annotate({ + identifier: "QuestionRejected", +}) + +const Asked = define({ type: "question.asked", schema: Request.fields }) +const RepliedEvent = define({ type: "question.replied", schema: Replied.fields }) +const RejectedEvent = define({ type: "question.rejected", schema: Rejected.fields }) +export const Event = { + Asked, + Replied: RepliedEvent, + Rejected: RejectedEvent, + Definitions: inventory(Asked, RepliedEvent, RejectedEvent), +} diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts new file mode 100644 index 00000000000..435ddb058b5 --- /dev/null +++ b/packages/schema/src/v1/session.ts @@ -0,0 +1,722 @@ +export * as SessionV1 from "./session" + +import { Effect, Schema, Types } from "effect" +import { define, inventory } from "../event" +import { FileDiff } from "../file-diff" +import { Project } from "../project" +import { Provider } from "../provider" +import { Model } from "../model" +import { NonNegativeInt, optional, statics } from "../schema" +import { ascending } from "../identifier" +import { SessionID } from "../session-id" +import { WorkspaceID } from "../workspace-id" +import { PermissionV1 } from "./permission" + +const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) + +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( + Schema.brand("MessageID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + ascending()) })), +) +export type MessageID = typeof MessageID.Type + +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( + Schema.brand("PartID"), + statics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + ascending()) })), +) +export type PartID = typeof PartID.Type + +const namedError = (name: Name, fields: Fields) => { + const schema = Schema.Struct({ name: Schema.Literal(name), data: Schema.Struct(fields) }).annotate({ + identifier: name, + }) + return { Schema: schema, EffectSchema: schema } +} + +export const OutputLengthError = namedError("MessageOutputLengthError", {}) + +export const AuthError = namedError("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) + +export const AbortedError = namedError("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = namedError("StructuredOutputError", { + message: Schema.String, + retries: NonNegativeInt, +}) +export const APIError = namedError("APIError", { + message: Schema.String, + statusCode: Schema.optional(NonNegativeInt), + isRetryable: Schema.Boolean, + responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), + responseBody: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) +export type APIError = Schema.Schema.Type +export const ContextOverflowError = namedError("ContextOverflowError", { + message: Schema.String, + responseBody: Schema.optional(Schema.String), +}) +export const ContentFilterError = namedError("ContentFilterError", { + message: Schema.String, +}) + +export class OutputFormatText extends Schema.Class("OutputFormatText")({ + type: Schema.Literal("text"), +}) {} + +export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ + type: Schema.Literal("json_schema"), + schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), + retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), +}) {} + +export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ + discriminator: "type", + identifier: "OutputFormat", +}) +export type OutputFormat = Schema.Schema.Type + +const partBase = { + id: PartID, + sessionID: SessionID, + messageID: MessageID, +} + +export const SnapshotPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("snapshot"), + snapshot: Schema.String, +}).annotate({ identifier: "SnapshotPart" }) +export type SnapshotPart = Types.DeepMutable> + +export const PatchPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("patch"), + hash: Schema.String, + files: Schema.Array(Schema.String), +}).annotate({ identifier: "PatchPart" }) +export type PatchPart = Types.DeepMutable> + +export const TextPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPart" }) +export type TextPart = Types.DeepMutable> + +export const ReasoningPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("reasoning"), + text: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), +}).annotate({ identifier: "ReasoningPart" }) +export type ReasoningPart = Types.DeepMutable> + +const filePartSourceBase = { + text: Schema.Struct({ + value: Schema.String, + start: Schema.Finite, + end: Schema.Finite, + }).annotate({ identifier: "FilePartSourceText" }), +} + +export const Range = Schema.Struct({ + start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), + end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }), +}).annotate({ identifier: "Range" }) +export type Range = typeof Range.Type + +export const FileSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("file"), + path: Schema.String, +}).annotate({ identifier: "FileSource" }) + +export const SymbolSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("symbol"), + path: Schema.String, + range: Range, + name: Schema.String, + kind: NonNegativeInt, +}).annotate({ identifier: "SymbolSource" }) + +export const ResourceSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("resource"), + clientName: Schema.String, + uri: Schema.String, +}).annotate({ identifier: "ResourceSource" }) + +export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ + discriminator: "type", + identifier: "FilePartSource", +}) + +export const FilePart = Schema.Struct({ + ...partBase, + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePart" }) +export type FilePart = Types.DeepMutable> + +export const AgentPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPart" }) +export type AgentPart = Types.DeepMutable> + +export const CompactionPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("compaction"), + auto: Schema.Boolean, + overflow: Schema.optional(Schema.Boolean), + tail_start_id: Schema.optional(MessageID), +}).annotate({ identifier: "CompactionPart" }) +export type CompactionPart = Types.DeepMutable> + +export const SubtaskPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPart" }) +export type SubtaskPart = Types.DeepMutable> + +export const RetryPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("retry"), + attempt: NonNegativeInt, + error: APIError.EffectSchema, + time: Schema.Struct({ + created: NonNegativeInt, + }), +}).annotate({ identifier: "RetryPart" }) +export type RetryPart = Omit>, "error"> & { + error: APIError +} + +export const StepStartPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-start"), + snapshot: Schema.optional(Schema.String), +}).annotate({ identifier: "StepStartPart" }) +export type StepStartPart = Types.DeepMutable> + +export const StepFinishPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-finish"), + reason: Schema.String, + snapshot: Schema.optional(Schema.String), + // kilocode_change start + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + generationID: Schema.optional(Schema.String), + vercelID: Schema.optional(Schema.String), + metrics: Schema.optional( + Schema.Struct({ + prompt: Schema.optional(Schema.Finite), + generation: Schema.optional(Schema.Finite), + source: Schema.Literals(["provider", "computed"]), + }), + ), + // Wall-clock timestamps + active generation duration captured at the + // session processor. The webview's weighted throughput aggregator uses + // `time.elapsed` (active model-generation duration in milliseconds, + // excluding tool execution and idle waiting) to weight the per-turn + // rate. Optional so legacy persisted sessions keep decoding. + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + elapsed: Schema.Finite, + }), + ), + // kilocode_change end + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), +}).annotate({ identifier: "StepFinishPart" }) +export type StepFinishPart = Types.DeepMutable> + +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.Record(Schema.String, Schema.Any), + raw: Schema.String, +}).annotate({ identifier: "ToolStatePending" }) +export type ToolStatePending = Types.DeepMutable> + +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Any), + title: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateRunning" }) +export type ToolStateRunning = Types.DeepMutable> + +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Any), + output: Schema.String, + title: Schema.String, + metadata: Schema.Record(Schema.String, Schema.Any), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + compacted: Schema.optional(NonNegativeInt), + }), + attachments: Schema.optional(Schema.Array(FilePart)), +}).annotate({ identifier: "ToolStateCompleted" }) +export type ToolStateCompleted = Types.DeepMutable> + +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Any), + error: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + }), +}).annotate({ identifier: "ToolStateError" }) +export type ToolStateError = Types.DeepMutable> + +export const ToolState = Schema.Union([ + ToolStatePending, + ToolStateRunning, + ToolStateCompleted, + ToolStateError, +]).annotate({ + discriminator: "status", + identifier: "ToolState", +}) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export const ToolPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("tool"), + callID: Schema.String, + tool: Schema.String, + state: ToolState, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "ToolPart" }) +export type ToolPart = Omit>, "state"> & { + state: ToolState +} + +const messageBase = { + id: MessageID, + sessionID: partBase.sessionID, +} + +// kilocode_change start - editor context captured by the VS Code client +export const EditorContext = Schema.Struct({ + directory: Schema.optional(Schema.String), + worktree: Schema.optional(Schema.String), + visibleFiles: Schema.optional(Schema.Array(Schema.String)), + openTabs: Schema.optional(Schema.Array(Schema.String)), + activeFile: Schema.optional(Schema.String), + shell: Schema.optional(Schema.String), +}) +export type EditorContext = Types.DeepMutable> +// kilocode_change end + +export const User = Schema.Struct({ + ...messageBase, + role: Schema.Literal("user"), + time: Schema.Struct({ + created: Timestamp, + }), + format: Schema.optional(Format), + summary: Schema.optional( + Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), + diffs: Schema.Array(FileDiff.Info), + }), + ), + agent: Schema.String, + model: Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + variant: Schema.optional(Schema.String), + }), + system: Schema.optional(Schema.String), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + // kilocode_change start + editorContext: Schema.optional(EditorContext), + // kilocode_change end +}).annotate({ identifier: "UserMessage" }) +export type User = Types.DeepMutable> + +export const Part = Schema.Union([ + TextPart, + SubtaskPart, + ReasoningPart, + FilePart, + ToolPart, + StepStartPart, + StepFinishPart, + SnapshotPart, + PatchPart, + AgentPart, + RetryPart, + CompactionPart, +]).annotate({ discriminator: "type", identifier: "Part" }) +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +const AssistantErrorSchema = Schema.Union([ + AuthError.EffectSchema, + namedError("UnknownError", { message: Schema.String, ref: Schema.optional(Schema.String) }).EffectSchema, + OutputLengthError.EffectSchema, + AbortedError.EffectSchema, + StructuredOutputError.EffectSchema, + ContextOverflowError.EffectSchema, + ContentFilterError.EffectSchema, + APIError.EffectSchema, +]).annotate({ discriminator: "name" }) +type AssistantError = Schema.Schema.Type + +export const TextPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}).annotate({ identifier: "TextPartInput" }) +export type TextPartInput = Types.DeepMutable> + +export const FilePartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(FilePartSource), +}).annotate({ identifier: "FilePartInput" }) +export type FilePartInput = Types.DeepMutable> + +export const AgentPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, + }), + ), +}).annotate({ identifier: "AgentPartInput" }) +export type AgentPartInput = Types.DeepMutable> + +export const SubtaskPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: Provider.ID, + modelID: Model.ID, + }), + ), + variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant + command: Schema.optional(Schema.String), +}).annotate({ identifier: "SubtaskPartInput" }) +export type SubtaskPartInput = Types.DeepMutable> + +export const Assistant = Schema.Struct({ + ...messageBase, + role: Schema.Literal("assistant"), + time: Schema.Struct({ + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), + }), + error: Schema.optional(AssistantErrorSchema), + parentID: MessageID, + modelID: Model.ID, + providerID: Provider.ID, + mode: Schema.String, + agent: Schema.String, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, + }), + summary: Schema.optional(Schema.Boolean), + cost: Schema.Finite, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + structured: Schema.optional(Schema.Any), + variant: Schema.optional(Schema.String), + finish: Schema.optional(Schema.String), +}).annotate({ identifier: "AssistantMessage" }) +export type Assistant = Omit>, "error"> & { + error?: AssistantError +} + +export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) +export type Info = User | Assistant + +export const WithParts = Schema.Struct({ + info: Info, + parts: Schema.Array(Part), +}) +export type WithParts = { + info: Info + parts: Part[] +} + +const options = { + durable: { + aggregate: "sessionID", + version: 1, + }, +} as const + +const SessionSummary = Schema.Struct({ + additions: Schema.Finite, + deletions: Schema.Finite, + files: Schema.Finite, + diffs: optional(Schema.Array(FileDiff.Info)), +}) + +const SessionTokens = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +const SessionShare = Schema.Struct({ + url: Schema.String, +}) + +const SessionRevert = Schema.Struct({ + messageID: MessageID, + partID: optional(PartID), + snapshot: optional(Schema.String), + diff: optional(Schema.String), +}) + +const SessionModel = Schema.Struct({ + id: Model.ID, + providerID: Provider.ID, + variant: optional(Schema.String), +}) + +export const SessionInfo = Schema.Struct({ + id: SessionID, + slug: Schema.String, + projectID: Project.ID, + workspaceID: optional(WorkspaceID), + directory: Schema.String, + path: optional(Schema.String), + parentID: optional(SessionID), + summary: optional(SessionSummary), + cost: optional(Schema.Finite), + tokens: optional(SessionTokens), + share: optional(SessionShare), + title: Schema.String, + agent: optional(Schema.String), + model: optional(SessionModel), + version: Schema.String, + metadata: optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optional(NonNegativeInt), + archived: optional(Schema.Finite), + }), + permission: optional(PermissionV1.Ruleset), + revert: optional(SessionRevert), +}).annotate({ identifier: "Session" }) +export type SessionInfo = typeof SessionInfo.Type + +const events = { + Created: define({ + type: "session.created", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Updated: define({ + type: "session.updated", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + Deleted: define({ + type: "session.deleted", + ...options, + schema: { + sessionID: SessionID, + info: SessionInfo, + }, + }), + MessageUpdated: define({ + type: "message.updated", + ...options, + schema: { + sessionID: SessionID, + info: Info, + }, + }), + MessageRemoved: define({ + type: "message.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + }, + }), + PartUpdated: define({ + type: "message.part.updated", + ...options, + schema: { + sessionID: SessionID, + part: Part, + time: Schema.Finite, + }, + }), + PartRemoved: define({ + type: "message.part.removed", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + }, + }), +} + +export const PartDelta = define({ + type: "message.part.delta", + schema: { + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + field: Schema.String, + delta: Schema.String, + }, +}) + +export const Diff = define({ + type: "session.diff", + schema: { + sessionID: SessionID, + diff: Schema.Array(FileDiff.Info), + }, +}) + +export const Error = define({ + type: "session.error", + schema: { + sessionID: Schema.optional(SessionID), + error: Assistant.fields.error, + }, +}) + +export const Event = { + ...events, + PartDelta, + Diff, + Error, + Definitions: inventory( + events.Created, + events.Updated, + events.Deleted, + events.MessageUpdated, + events.MessageRemoved, + events.PartUpdated, + events.PartRemoved, + PartDelta, + Diff, + Error, + ), +} diff --git a/packages/schema/src/vcs-event.ts b/packages/schema/src/vcs-event.ts new file mode 100644 index 00000000000..1c0d720dc4b --- /dev/null +++ b/packages/schema/src/vcs-event.ts @@ -0,0 +1,14 @@ +export * as VcsEvent from "./vcs-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" + +export const BranchUpdated = Event.define({ + type: "vcs.branch.updated", + schema: { + branch: optional(Schema.String), + }, +}) + +export const Definitions = Event.inventory(BranchUpdated) diff --git a/packages/schema/src/workspace-event.ts b/packages/schema/src/workspace-event.ts new file mode 100644 index 00000000000..82e15e07719 --- /dev/null +++ b/packages/schema/src/workspace-event.ts @@ -0,0 +1,32 @@ +export * as WorkspaceEvent from "./workspace-event" + +import { Schema } from "effect" +import { Event } from "./event" +import { WorkspaceID } from "./workspace-id" + +export const ConnectionStatus = Schema.Struct({ + workspaceID: WorkspaceID, + status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), +}).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" }) +export interface ConnectionStatus extends Schema.Schema.Type {} + +export const Ready = Event.define({ + type: "workspace.ready", + schema: { + name: Schema.String, + }, +}) + +export const Failed = Event.define({ + type: "workspace.failed", + schema: { + message: Schema.String, + }, +}) + +export const Status = Event.define({ + type: "workspace.status", + schema: ConnectionStatus.fields, +}) + +export const Definitions = Event.inventory(Ready, Failed, Status) diff --git a/packages/schema/src/workspace-id.ts b/packages/schema/src/workspace-id.ts new file mode 100644 index 00000000000..e43e9673f63 --- /dev/null +++ b/packages/schema/src/workspace-id.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect" +import { ascending } from "./identifier" +import { statics } from "./schema" + +export const WorkspaceID = Schema.String.check(Schema.isStartsWith("wrk")).pipe( + Schema.brand("WorkspaceV2.ID"), + statics((schema) => { + const create = () => schema.make("wrk_" + ascending()) + return { + ascending: (id?: string) => { + if (!id) return create() + if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`) + return schema.make(id) + }, + create, + } + }), +) +export type WorkspaceID = typeof WorkspaceID.Type diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts new file mode 100644 index 00000000000..ce35bf3b245 --- /dev/null +++ b/packages/schema/src/workspace.ts @@ -0,0 +1,9 @@ +export * as Workspace from "./workspace" + +import { WorkspaceEvent } from "./workspace-event" +import { WorkspaceID } from "./workspace-id" + +export const ID = WorkspaceID +export type ID = WorkspaceID + +export const Event = WorkspaceEvent diff --git a/packages/schema/src/worktree-event.ts b/packages/schema/src/worktree-event.ts new file mode 100644 index 00000000000..c42ea5821e1 --- /dev/null +++ b/packages/schema/src/worktree-event.ts @@ -0,0 +1,22 @@ +export * as WorktreeEvent from "./worktree-event" + +import { Schema } from "effect" +import { optional } from "./schema" +import { Event } from "./event" + +export const Ready = Event.define({ + type: "worktree.ready", + schema: { + name: Schema.String, + branch: optional(Schema.String), + }, +}) + +export const Failed = Event.define({ + type: "worktree.failed", + schema: { + message: Schema.String, + }, +}) + +export const Definitions = Event.inventory(Ready, Failed) diff --git a/packages/schema/sst-env.d.ts b/packages/schema/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/packages/schema/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/schema/test/compatibility.test.ts b/packages/schema/test/compatibility.test.ts new file mode 100644 index 00000000000..fa2cf265c42 --- /dev/null +++ b/packages/schema/test/compatibility.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test" +import { FileSystem } from "../src/filesystem" + +describe("schema compatibility", () => { + test("moved class schemas remain constructible", () => { + const input = new FileSystem.FindInput({ query: "src" }) + expect(input).toBeInstanceOf(FileSystem.FindInput) + expect(input.query).toBe("src") + }) +}) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts new file mode 100644 index 00000000000..cf83dbb2889 --- /dev/null +++ b/packages/schema/test/contract-hygiene.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Agent } from "../src/agent" +import { FileSystem } from "../src/filesystem" +import { Model } from "../src/model" +import { Project } from "../src/project" +import { Pty } from "../src/pty" +import { Question } from "../src/question" +import { Session } from "../src/session" +import { SessionEvent } from "../src/session-event" +import { SessionTodo } from "../src/session-todo" +import { optional } from "../src/schema" + +describe("contract hygiene", () => { + test("optional properties preserve transformations and omit undefined while encoding", () => { + const Value = Schema.Struct({ value: optional(Schema.FiniteFromString) }) + expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) + expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" }) + expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({}) + }) + + test("todo status and priority preserve arbitrary strings", () => { + const decode = Schema.decodeUnknownSync(SessionTodo.Info) + expect(decode({ content: "ship", status: "waiting", priority: "urgent" })).toEqual({ + content: "ship", + status: "waiting", + priority: "urgent", + }) + }) + + test("current ID constructors expose create", () => { + expect(Question.ID.create()).toStartWith("que_") + expect(Pty.ID.create()).toStartWith("pty_") + }) + + test("reusable public identifiers are stable and unique", () => { + const identifiers = [ + Agent.Color, + FileSystem.Submatch, + Model.Ref, + Model.Capabilities, + Model.Cost, + Model.Api, + Project.Icon, + Project.Commands, + Project.Time, + Project.Info, + Pty.Info, + Session.ListAnchor, + ].map((schema) => schema.ast.annotations?.identifier) + + expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) + expect(new Set(identifiers).size).toBe(identifiers.length) + }) + + test("current source avoids Any and mutable contract wrappers", async () => { + const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter( + (file) => !file.endsWith("-v1.ts"), + ) + const source = await Promise.all( + files.map((file) => Bun.file(new URL(`../src/${file}`, import.meta.url)).text()), + ).then((values) => values.join("\n")) + + expect(source).not.toContain("Schema.Any") + expect(source).not.toContain("Schema.mutable") + }) +}) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts new file mode 100644 index 00000000000..5694afdd30d --- /dev/null +++ b/packages/schema/test/event-manifest.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src" +import { EventManifest } from "../src/event-manifest" +import { IdeEvent } from "../src/ide-event" +import { SessionEvent } from "../src/session-event" +import { SessionTodo } from "../src/session-todo" +import { SessionV1 } from "../src/session-v1" +import { WorkspaceEvent } from "../src/workspace-event" + +describe("public event manifest", () => { + test("owns the complete public event surface", () => { + expect(EventManifest.ServerDefinitions.length).toBe(55) + expect(EventManifest.Definitions.length).toBe(85) + expect(SessionV1.Event.Definitions).toEqual([ + SessionV1.Event.Created, + SessionV1.Event.Updated, + SessionV1.Event.Deleted, + SessionV1.Event.MessageUpdated, + SessionV1.Event.MessageRemoved, + SessionV1.Event.PartUpdated, + SessionV1.Event.PartRemoved, + SessionV1.Event.PartDelta, + SessionV1.Event.Diff, + SessionV1.Event.Error, + ]) + expect(EventManifest.Latest.size).toBe(85) + expect(EventManifest.Durable.size).toBe(32) + }) + + test("uses canonical definitions for current public events", () => { + expect(Session.Event).toBe(SessionEvent) + expect(Session.Event.Definitions).toBe(SessionEvent.Definitions) + expect(Workspace.Event).toBe(WorkspaceEvent) + expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions) + expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated) + expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) + expect(Project.Event.Definitions).toEqual([Project.Event.Updated]) + expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited]) + expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated]) + expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied]) + expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) + expect(EventManifest.Latest.has("ide.installed")).toBe(false) + expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) + expect(EventManifest.Definitions.slice(40, 43)).toEqual([ + SessionV1.Event.PartDelta, + SessionV1.Event.Diff, + SessionV1.Event.Error, + ]) + expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) + expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + }) +}) diff --git a/packages/schema/test/event.test.ts b/packages/schema/test/event.test.ts new file mode 100644 index 00000000000..380faa5a4ab --- /dev/null +++ b/packages/schema/test/event.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Event } from "../src/event" + +describe("public event schemas", () => { + test("definition is pure", () => { + const definitions = Event.inventory() + Event.define({ type: "test.pure", schema: { value: Schema.String } }) + expect(definitions).toEqual([]) + }) + + test("latest selection is independent of declaration order", () => { + const historical = Event.define({ + type: "test.versioned", + durable: { aggregate: "id", version: 1 }, + schema: { id: Schema.String }, + }) + const current = Event.define({ + type: "test.versioned", + durable: { aggregate: "id", version: 2 }, + schema: { id: Schema.String, value: Schema.String }, + }) + + expect(Event.latest([historical, current]).get(current.type)).toBe(current) + expect(Event.latest([current, historical]).get(current.type)).toBe(current) + }) + + test("durable definitions are indexed by type and version", () => { + const definition = Event.define({ + type: "test.durable", + durable: { aggregate: "id", version: 1 }, + schema: { id: Schema.String }, + }) + + expect(Event.durable([definition]).get("test.durable.1")).toBe(definition) + }) +}) diff --git a/packages/schema/test/legacy-event.test.ts b/packages/schema/test/legacy-event.test.ts new file mode 100644 index 00000000000..e43c5681f20 --- /dev/null +++ b/packages/schema/test/legacy-event.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { LegacyEvent } from "../src/legacy-event" +import { PermissionV1 } from "../src/permission-v1" +import { QuestionV1 } from "../src/question-v1" +import { Project } from "../src/project" +import { SessionV1 } from "../src/session-v1" + +describe("legacy public event schemas", () => { + test("owns all SessionV1 definitions", () => { + expect(SessionV1.Event.Definitions.map((event) => event.type)).toEqual([ + "session.created", + "session.updated", + "session.deleted", + "message.updated", + "message.removed", + "message.part.updated", + "message.part.removed", + "message.part.delta", + "session.diff", + "session.error", + ]) + const durable = SessionV1.Event.Definitions.filter((event) => event.durable !== undefined) + expect(durable).toHaveLength(7) + expect(durable.every((event) => event.durable?.aggregate === "sessionID")).toBe(true) + expect(durable.every((event) => event.durable?.version === 1)).toBe(true) + }) + + test("owns the legacy transient public definitions", () => { + expect([ + SessionV1.PartDelta.type, + SessionV1.Diff.type, + SessionV1.Error.type, + PermissionV1.Event.Asked.type, + PermissionV1.Event.Replied.type, + QuestionV1.Event.Asked.type, + QuestionV1.Event.Replied.type, + QuestionV1.Event.Rejected.type, + Project.Event.Updated.type, + LegacyEvent.CommandExecuted.type, + ]).toEqual([ + "message.part.delta", + "session.diff", + "session.error", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", + "project.updated", + "command.executed", + ]) + }) +}) diff --git a/packages/schema/test/v1-isolation.test.ts b/packages/schema/test/v1-isolation.test.ts new file mode 100644 index 00000000000..2710d855671 --- /dev/null +++ b/packages/schema/test/v1-isolation.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { LegacyEvent } from "../src/legacy-event" +import { PermissionV1 } from "../src/permission-v1" +import { QuestionV1 } from "../src/question-v1" +import { SessionV1 } from "../src/session-v1" +import { LegacyEvent as IsolatedLegacyEvent } from "../src/v1/legacy-event" +import { PermissionV1 as IsolatedPermissionV1 } from "../src/v1/permission" +import { QuestionV1 as IsolatedQuestionV1 } from "../src/v1/question" +import { SessionV1 as IsolatedSessionV1 } from "../src/v1/session" + +test("compatibility entrypoints preserve isolated V1 schema identity", () => { + expect(LegacyEvent).toBe(IsolatedLegacyEvent) + expect(PermissionV1).toBe(IsolatedPermissionV1) + expect(QuestionV1).toBe(IsolatedQuestionV1) + expect(SessionV1).toBe(IsolatedSessionV1) +}) + +test("current source does not import the V1 subtree directly", async () => { + const allowed = new Set(["legacy-event.ts", "permission-v1.ts", "question-v1.ts", "session-v1.ts"]) + const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter( + (file) => !allowed.has(file), + ) + const directImports = await Promise.all( + files.map(async (file) => ({ file, source: await Bun.file(new URL(`../src/${file}`, import.meta.url)).text() })), + ).then((values) => values.filter((value) => value.source.includes('from "./v1/'))) + + expect(directImports).toEqual([]) +}) diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json new file mode 100644 index 00000000000..681f85136aa --- /dev/null +++ b/packages/schema/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "types": ["bun"] + } +} diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md new file mode 100644 index 00000000000..8f584c8a521 --- /dev/null +++ b/packages/sdk-next/README.md @@ -0,0 +1,29 @@ +# @opencode-ai/sdk-next + +Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@kilocode/sdk` after its consumers migrate. + +The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client. + +```ts +import { OpenCode } from "@opencode-ai/sdk-next" + +const opencode = yield * OpenCode.create() +const session = yield * opencode.sessions.get({ sessionID }) +``` + +It also exports `Tool` and exposes local-only `tools.register(...)`, replacing the former `@opencode-ai/core/public` facade. Registration uses Core's host-level `ApplicationTools` service shared by the host's Locations; each Location retains its own `ToolRegistry` for overlay, lookup, and settlement. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. + +`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message. + +The same constructor is available as a service Layer: + +```ts +const program = Effect.gen(function* () { + const opencode = yield* OpenCode.Service + return yield* opencode.sessions.get({ sessionID }) +}) + +yield * program.pipe(Effect.provide(OpenCode.layer)) +``` + +`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation. diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json new file mode 100644 index 00000000000..dc4a77656cd --- /dev/null +++ b/packages/sdk-next/package.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/sdk-next", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test --timeout 5000", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 10000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "version": "7.4.16" +} diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts new file mode 100644 index 00000000000..fc23219fc40 --- /dev/null +++ b/packages/sdk-next/src/index.ts @@ -0,0 +1,17 @@ +export * as OpenCode from "./opencode" +export * as Tool from "./tool" + +export { ClientError } from "@opencode-ai/client/effect" +export { + AbsolutePath, + Agent, + Location, + Model, + Prompt, + Provider, + RelativePath, + Session, + SessionInput, + SessionMessage, +} from "@opencode-ai/client/effect" +export type { OpenCodeEvent } from "@opencode-ai/client/effect" diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts new file mode 100644 index 00000000000..096b46d4b71 --- /dev/null +++ b/packages/sdk-next/src/opencode.ts @@ -0,0 +1,49 @@ +import { OpenCode } from "@opencode-ai/client/effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { createEmbeddedRoutes } from "@opencode-ai/server/routes" +import { Context, Effect, Layer, Scope } from "effect" +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" + +export const create = Effect.fn("OpenCode.create")(function* () { + const scope = yield* Scope.Scope + const memoMap = yield* Layer.makeMemoMap + const context = yield* Layer.buildWithMemoMap( + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, PermissionSaved.node])), + memoMap, + scope, + ) + const tools = Context.get(context, ApplicationTools.Service) + const permissions = Context.get(context, PermissionSaved.Service) + const web = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + createEmbeddedRoutes().pipe( + HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true, memoMap }, + ), + ), + (web) => Effect.promise(web.dispose), + ) + const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), { + preconnect: () => undefined, + }) satisfies typeof globalThis.fetch + const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetch), + ) + return { + ...client, + tools: { register: tools.register }, + } +}) + +export type Interface = Effect.Success> + +export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} + +export const layer = Layer.effect(Service, create()) diff --git a/packages/sdk-next/src/tool.ts b/packages/sdk-next/src/tool.ts new file mode 100644 index 00000000000..4b572a6260c --- /dev/null +++ b/packages/sdk-next/src/tool.ts @@ -0,0 +1,2 @@ +export { Failure, RegistrationError, make } from "@opencode-ai/core/tool/tool" +export type { AnyTool, Content, Context, Definition } from "@opencode-ai/core/tool/tool" diff --git a/packages/sdk-next/sst-env.d.ts b/packages/sdk-next/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/packages/sdk-next/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts new file mode 100644 index 00000000000..5841050619b --- /dev/null +++ b/packages/sdk-next/test/embedded.test.ts @@ -0,0 +1,226 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect" +import type { OpenCodeEvent } from "../src" + +// kilocode_change start - retry Windows SQLite locks until GC finalizers release them +const cleanup = async (dir: string, retries = 30): Promise => { + try { + await rm(dir, { recursive: true, force: true }) + } catch (error) { + if (retries === 0 || !error || typeof error !== "object" || !("code" in error) || error.code !== "EBUSY") + throw error + Bun.gc(true) + await Bun.sleep(100) + return cleanup(dir, retries - 1) + } +} +// kilocode_change end + +test("embedded client uses the real router and handlers", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) + const database = Flag.KILO_DB + Flag.KILO_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") }) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + yield* opencode.tools.register({ + embedded_tool: Tool.make({ + description: "Embedded test tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + }) + + const created = yield* opencode.sessions.create({ + id: sessionID, + agent: Agent.ID.make("build"), + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.switchModel({ sessionID, model }) + const selected = yield* opencode.sessions.get({ sessionID }) + const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) }) + const active = yield* opencode.sessions.active() + const admitted = yield* opencode.sessions.prompt({ + sessionID, + prompt: Prompt.make({ text: "Do not run" }), + resume: false, + }) + const context = yield* opencode.sessions.context({ sessionID }) + const wake = yield* opencode.sessions.prompt({ + sessionID, + prompt: Prompt.make({ text: "Promote this input" }), + }) + const prompted = yield* opencode.sessions.events({ sessionID }).pipe( + Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id), + Stream.runHead, + Effect.timeout("10 seconds"), + Effect.map(Option.getOrThrow), + ) + const wakeContext = yield* opencode.sessions.context({ sessionID }) + const event = yield* opencode.sessions + .events({ sessionID }) + .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined)) + const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe( + Option.getOrThrow, + ) + const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id }) + yield* opencode.sessions.interrupt({ sessionID }) + const other = yield* opencode.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`) + const missing = yield* Effect.all( + [ + opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), + opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), + opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), + ], + { concurrency: "unbounded" }, + ) + const missingMessage = yield* Effect.flip( + opencode.sessions.message({ + sessionID: other.id, + messageID: modelMessage.id, + }), + ) + + expect(created.id).toBe(sessionID) + expect(selected.model?.id).toBe(model.id) + expect(selected.model?.providerID).toBe(model.providerID) + expect(page.data.some((session) => session.id === sessionID)).toBe(true) + expect(active).toEqual({}) + expect(admitted.sessionID).toBe(sessionID) + expect(prompted.type).toBe("session.next.prompted") + expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) + expect(context.some((message) => message.type === "model-switched")).toBe(true) + expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) + expect(message).toEqual(modelMessage) + expect(missing.map((error) => error._tag)).toEqual([ + "SessionNotFoundError", + "SessionNotFoundError", + "SessionNotFoundError", + ]) + expect(missingMessage._tag).toBe("MessageNotFoundError") + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.KILO_DB = database + await cleanup(directory) // kilocode_change + } +}) + +test("Location-owned runner events reach the ready global client", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-")) + const database = Flag.KILO_DB + Flag.KILO_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + const connected = yield* Latch.make(false) + const prompted = yield* Deferred.make() + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => + event.type === "server.connected" + ? connected.open + : event.type === "session.next.prompted" && event.data.sessionID === sessionID + ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) }) + + const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) + expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) })) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.KILO_DB = database + await cleanup(directory) // kilocode_change + } +}, 10_000) + +test("independent embedded hosts do not share live notifications", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-")) + const database = Flag.KILO_DB + Flag.KILO_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const first = yield* OpenCode.create() + const second = yield* OpenCode.create() + const firstReady = yield* Latch.make(false) + const secondReady = yield* Latch.make(false) + const firstEvent = yield* Latch.make(false) + const secondEvent = yield* Latch.make(false) + const observe = (ready: Latch.Latch, event: Latch.Latch) => + Stream.runForEach((notification: OpenCodeEvent) => + notification.type === "server.connected" + ? ready.open + : notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID + ? event.open + : Effect.void, + ) + + yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped) + yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped) + yield* Effect.all([firstReady.await, secondReady.await], { discard: true }) + yield* first.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") }) + + yield* firstEvent.await.pipe(Effect.timeout("2 seconds")) + expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.KILO_DB = database + await cleanup(directory) // kilocode_change + } +}, 10_000) + +test("embedded client is available as a Layer service", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) + const database = Flag.KILO_DB + Flag.KILO_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const created = await Effect.runPromise( + Effect.gen(function* () { + const opencode = yield* OpenCode.Service + return yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + }).pipe(Effect.provide(OpenCode.layer), Effect.scoped), + ) + + expect(created.id).toBe(sessionID) + } finally { + Flag.KILO_DB = database + await cleanup(directory) // kilocode_change + } +}) diff --git a/packages/sdk-next/test/import-boundaries.test.ts b/packages/sdk-next/test/import-boundaries.test.ts new file mode 100644 index 00000000000..f6a7d178e20 --- /dev/null +++ b/packages/sdk-next/test/import-boundaries.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join, resolve, sep } from "node:path" + +const directory = resolve(import.meta.dir, "..") +const client = resolve(import.meta.dir, "../../client") +const core = resolve(import.meta.dir, "../../core") +const server = resolve(import.meta.dir, "../../server") + +test("bundles the client and in-memory host", async () => { + const inputs = await bundleInputs() + + expect(within(inputs, client).length).toBeGreaterThan(0) + expect(within(inputs, core).length).toBeGreaterThan(0) + expect(within(inputs, server).length).toBeGreaterThan(0) +}) + +async function bundleInputs() { + const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-")) + const entrypoint = join(temporary, "index.ts") + const metafile = join(temporary, "meta.json") + try { + await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"') + const child = Bun.spawn( + [ + process.execPath, + "build", + entrypoint, + "--target=bun", + "--format=esm", + "--packages=bundle", + `--metafile=${metafile}`, + `--outdir=${join(temporary, "out")}`, + ], + { cwd: directory, stdout: "pipe", stderr: "pipe" }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (exitCode !== 0) throw new Error(stdout + stderr) + const metadata = await Bun.file(metafile).json() + return Object.keys(metadata.inputs).map((input) => resolve(directory, input)) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +function within(inputs: ReadonlyArray, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/sdk-next/tsconfig.json b/packages/sdk-next/tsconfig.json new file mode 100644 index 00000000000..681f85136aa --- /dev/null +++ b/packages/sdk-next/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "types": ["bun"] + } +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c9cb4a042cf..51c5a266464 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -5,6 +5,7 @@ "type": "module", "license": "MIT", "scripts": { + "test": "bun test", "typecheck": "tsgo --noEmit", "build": "bun ./script/build.ts" }, @@ -15,7 +16,8 @@ "./v2": "./src/v2/index.ts", "./v2/client": "./src/v2/client.ts", "./v2/gen/client": "./src/v2/gen/client/index.ts", - "./v2/server": "./src/v2/server.ts" + "./v2/server": "./src/v2/server.ts", + "./v2/types": "./src/v2/gen/types.gen.ts" }, "files": [ "dist" diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index eb8e7b59766..3865b60fa1a 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -13,6 +13,37 @@ const opencode = path.resolve(dir, "../../opencode") await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +const document = (await Bun.file("./openapi.json").json()) as { + components?: { schemas?: Record } + [key: string]: unknown +} +const schemas = document.components?.schemas +if (schemas) { + const reachable = new Set() + const visit = (value: unknown) => { + if (Array.isArray(value)) { + value.forEach(visit) + return + } + if (typeof value !== "object" || value === null) return + for (const [key, child] of Object.entries(value)) { + if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) { + const name = child.slice("#/components/schemas/".length) + if (reachable.has(name)) continue + reachable.add(name) + visit(schemas[name]) + } else { + visit(child) + } + } + } + visit({ ...document, components: { ...document.components, schemas: undefined } }) + for (const name of Object.keys(schemas)) { + if (/^SessionNext\w+1$/.test(name) && !reachable.has(name)) delete schemas[name] + } + await Bun.write("./openapi.json", JSON.stringify(document)) +} + await createClient({ input: "./openapi.json", output: { @@ -40,6 +71,29 @@ await createClient({ ], }) +const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text() +if (/export type SessionNext\w+1 =/.test(generatedTypes)) { + throw new Error("Session history generated duplicate Session event variants") +} +const historyTypesPatched = generatedTypes.replace( + /(export type V2SessionHistoryData = \{[\s\S]*?query\?: \{\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historyTypesPatched === generatedTypes) { + throw new Error("Session history numeric query patch did not apply") +} +await Bun.write("./src/v2/gen/types.gen.ts", historyTypesPatched) + +const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text() +const historySdkPatched = generatedSdk.replace( + /(Get session history[\s\S]*?parameters: \{\s*sessionID: string[;,]\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historySdkPatched === generatedSdk) { + throw new Error("Session history numeric SDK patch did not apply") +} +await Bun.write("./src/v2/gen/sdk.gen.ts", historySdkPatched) + // Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the // endpoint's TError into the second generic of ServerSentEventsResult, which // is the AsyncGenerator's TReturn slot. Iterator return values have nothing diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 8c77db8ce01..8f5e80e9db8 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -262,6 +262,7 @@ import type { MemoryShowResponses, MemoryStatusErrors, MemoryStatusResponses, + ModelRef, MoveSessionDestination, NetworkListErrors, NetworkListResponses, @@ -292,17 +293,20 @@ import type { PermissionSaveAlwaysRulesErrors, PermissionSaveAlwaysRulesResponses, PermissionV2Reply, + PermissionV2Source, + ProjectCommands, ProjectCurrentErrors, ProjectCurrentResponses, ProjectDirectoriesErrors, ProjectDirectoriesResponses, + ProjectIcon, ProjectInitGitErrors, ProjectInitGitResponses, ProjectListErrors, ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, - Prompt, + PromptInput, ProviderAuthErrors, ProviderAuthResponses, ProviderListErrors, @@ -523,18 +527,32 @@ import type { V2QuestionRequestListResponses, V2ReferenceListErrors, V2ReferenceListResponses, + V2SessionActiveErrors, + V2SessionActiveResponses, V2SessionCompactErrors, V2SessionCompactResponses, V2SessionContextErrors, V2SessionContextResponses, V2SessionCreateErrors, V2SessionCreateResponses, + V2SessionEventsErrors, + V2SessionEventsResponses, V2SessionGetErrors, V2SessionGetResponses, + V2SessionHistoryErrors, + V2SessionHistoryResponses, + V2SessionInterruptErrors, + V2SessionInterruptResponses, V2SessionListErrors, V2SessionListResponses, + V2SessionMessageErrors, + V2SessionMessageResponses, V2SessionMessagesErrors, V2SessionMessagesResponses, + V2SessionPermissionCreateErrors, + V2SessionPermissionCreateResponses, + V2SessionPermissionGetErrors, + V2SessionPermissionGetResponses, V2SessionPermissionListErrors, V2SessionPermissionListResponses, V2SessionPermissionReplyErrors, @@ -547,6 +565,16 @@ import type { V2SessionQuestionRejectResponses, V2SessionQuestionReplyErrors, V2SessionQuestionReplyResponses, + V2SessionRevertClearErrors, + V2SessionRevertClearResponses, + V2SessionRevertCommitErrors, + V2SessionRevertCommitResponses, + V2SessionRevertStageErrors, + V2SessionRevertStageResponses, + V2SessionSwitchAgentErrors, + V2SessionSwitchAgentResponses, + V2SessionSwitchModelErrors, + V2SessionSwitchModelResponses, V2SessionWaitErrors, V2SessionWaitResponses, V2SkillListErrors, @@ -3254,17 +3282,8 @@ export class Project extends HeyApiClient { directory?: string workspace?: string name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } + icon?: ProjectIcon + commands?: ProjectCommands }, options?: Options, ) { @@ -3511,11 +3530,11 @@ export class Pty extends HeyApiClient { directory?: string workspace?: string title?: string - sessionID?: string | null size?: { rows: number cols: number } + sessionID?: string | null }, options?: Options, ) { @@ -3528,8 +3547,8 @@ export class Pty extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "title" }, - { in: "body", key: "sessionID" }, { in: "body", key: "size" }, + { in: "body", key: "sessionID" }, ], }, ], @@ -4491,6 +4510,8 @@ export class Session2 extends HeyApiClient { variant?: string snapshotInitialization?: "wait" editorContext?: { + directory?: string + worktree?: string visibleFiles?: Array openTabs?: Array activeFile?: string @@ -4853,6 +4874,8 @@ export class Session2 extends HeyApiClient { variant?: string snapshotInitialization?: "wait" editorContext?: { + directory?: string + worktree?: string visibleFiles?: Array openTabs?: Array activeFile?: string @@ -9200,6 +9223,91 @@ export class Agent extends HeyApiClient { } } +export class Revert extends HeyApiClient { + /** + * Stage session revert + * + * Stage or move a reversible session boundary and optionally apply its file changes. + */ + public stage( + parameters: { + sessionID: string + messageID?: string + files?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "messageID" }, + { in: "body", key: "files" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionRevertStageResponses, + V2SessionRevertStageErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/revert/stage", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Clear staged revert + */ + public clear( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post< + V2SessionRevertClearResponses, + V2SessionRevertClearErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/revert/clear", + ...options, + ...params, + }) + } + + /** + * Commit staged revert + */ + public commit( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post< + V2SessionRevertCommitResponses, + V2SessionRevertCommitErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/revert/commit", + ...options, + ...params, + }) + } +} + export class Permission2 extends HeyApiClient { /** * List session permission requests @@ -9224,6 +9332,93 @@ export class Permission2 extends HeyApiClient { }) } + /** + * Create permission request + * + * Evaluate and, when approval is required, create a permission request for a session. + */ + public create( + parameters: { + sessionID: string + id?: string + action?: string + resources?: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "action" }, + { in: "body", key: "resources" }, + { in: "body", key: "save" }, + { in: "body", key: "metadata" }, + { in: "body", key: "source" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionPermissionCreateResponses, + V2SessionPermissionCreateErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Get permission request + * + * Retrieve a pending permission request owned by a session. + */ + public get( + parameters: { + sessionID: string + requestID: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + V2SessionPermissionGetResponses, + V2SessionPermissionGetErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission/{requestID}", + ...options, + ...params, + }) + } + /** * Reply to pending permission request * @@ -9420,11 +9615,7 @@ export class Session4 extends HeyApiClient { parameters?: { id?: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef location?: LocationRef }, options?: Options, @@ -9454,6 +9645,18 @@ export class Session4 extends HeyApiClient { }) } + /** + * List active sessions + * + * Retrieve foreground Session drains currently owned by this Kilo process. Sessions absent from the result are inactive. + */ + public active(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/session/active", + ...options, + }) + } + /** * Get session * @@ -9473,6 +9676,84 @@ export class Session4 extends HeyApiClient { }) } + /** + * Switch session agent + * + * Switch the agent used by subsequent provider turns. + */ + public switchAgent( + parameters: { + sessionID: string + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchAgentResponses, + V2SessionSwitchAgentErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/agent", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Switch session model + * + * Switch the model used by subsequent provider turns. + */ + public switchModel( + parameters: { + sessionID: string + model?: ModelRef + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchModelResponses, + V2SessionSwitchModelErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/model", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send message * @@ -9482,7 +9763,7 @@ export class Session4 extends HeyApiClient { parameters: { sessionID: string id?: string - prompt?: Prompt + prompt?: PromptInput delivery?: "steer" | "queue" resume?: boolean }, @@ -9571,6 +9852,117 @@ export class Session4 extends HeyApiClient { }) } + /** + * Get session history + * + * Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages. + */ + public history( + parameters: { + sessionID: string + limit?: number + after?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "after" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/history", + ...options, + ...params, + }) + } + + /** + * Subscribe to session events + * + * Replay durable events after an aggregate sequence, then continue with new durable events. + */ + public events( + parameters: { + sessionID: string + after?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "after" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/api/session/{sessionID}/event", + ...options, + ...params, + }) + } + + /** + * Interrupt session execution + * + * Interrupt active execution owned by this Kilo process. Idle interruption is a no-op. + */ + public interrupt( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/interrupt", + ...options, + ...params, + }) + } + + /** + * Get session message + * + * Retrieve one projected message owned by the Session. + */ + public message( + parameters: { + sessionID: string + messageID: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message/{messageID}", + ...options, + ...params, + }) + } + /** * Get session messages * @@ -9605,6 +9997,11 @@ export class Session4 extends HeyApiClient { }) } + private _revert?: Revert + get revert(): Revert { + return (this._revert ??= new Revert({ client: this.client })) + } + private _permission?: Permission2 get permission(): Permission2 { return (this._permission ??= new Permission2({ client: this.client })) @@ -10304,22 +10701,12 @@ export class Event2 extends HeyApiClient { /** * Subscribe to events * - * Subscribe to native event payloads for a location. + * Subscribe to native event payloads for the server. */ - public subscribe( - parameters?: { - location?: { - directory?: string - workspace?: string - } - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + public subscribe(options?: Options) { return (options?.client ?? this.client).sse.get({ url: "/api/event", ...options, - ...params, }) } } @@ -10474,7 +10861,6 @@ export class Pty2 extends HeyApiClient { workspace?: string } title?: string - sessionID?: string size?: { rows: number cols: number @@ -10490,7 +10876,6 @@ export class Pty2 extends HeyApiClient { { in: "path", key: "ptyID" }, { in: "query", key: "location" }, { in: "body", key: "title" }, - { in: "body", key: "sessionID" }, { in: "body", key: "size" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c2d1cea6ba8..c9579f87ef7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,6 +5,95 @@ export type ClientOptions = { } export type Event = + | EventModelsDevRefreshed1 + | EventIntegrationUpdated1 + | EventIntegrationConnectionUpdated1 + | EventCatalogUpdated1 + | EventSessionCreated1 + | EventSessionUpdated1 + | EventSessionDeleted1 + | EventMessageUpdated1 + | EventMessageRemoved1 + | EventMessagePartUpdated1 + | EventMessagePartRemoved1 + | EventSessionNextAgentSwitched1 + | EventSessionNextModelSwitched1 + | EventSessionNextMoved1 + | EventSessionNextPrompted1 + | EventSessionNextPromptAdmitted1 + | EventSessionNextContextUpdated1 + | EventSessionNextSynthetic1 + | EventSessionNextShellStarted1 + | EventSessionNextShellEnded1 + | EventSessionNextStepStarted1 + | EventSessionNextStepEnded1 + | EventSessionNextStepFailed1 + | EventSessionNextTextStarted1 + | EventSessionNextTextDelta1 + | EventSessionNextTextEnded1 + | EventSessionNextReasoningStarted1 + | EventSessionNextReasoningDelta1 + | EventSessionNextReasoningEnded1 + | EventSessionNextToolInputStarted1 + | EventSessionNextToolInputDelta1 + | EventSessionNextToolInputEnded1 + | EventSessionNextToolCalled1 + | EventSessionNextToolProgress1 + | EventSessionNextToolSuccess1 + | EventSessionNextToolFailed1 + | EventSessionNextRetried1 + | EventSessionNextCompactionStarted1 + | EventSessionNextCompactionDelta1 + | EventSessionNextCompactionEnded1 + | EventSessionNextRevertStaged1 + | EventSessionNextRevertCleared1 + | EventSessionNextRevertCommitted1 + | EventMessagePartDelta1 + | EventSessionDiff1 + | EventSessionError1 + | EventInstallationUpdated1 + | EventInstallationUpdateAvailable1 + | EventFileEdited1 + | EventReferenceUpdated1 + | EventPermissionV2Asked1 + | EventPermissionV2Replied1 + | EventPluginAdded1 + | EventProjectDirectoriesUpdated1 + | EventFileWatcherUpdated1 + | EventPtyCreated1 + | EventPtyUpdated1 + | EventPtyExited1 + | EventPtyDeleted1 + | EventQuestionV2Asked1 + | EventQuestionV2Replied1 + | EventQuestionV2Rejected1 + | EventTodoUpdated1 + | EventLspUpdated1 + | EventPermissionAsked1 + | EventPermissionReplied1 + | EventTuiPromptAppend1 + | EventTuiCommandExecute1 + | EventTuiToastShow1 + | EventTuiSessionSelect1 + | EventMcpToolsChanged1 + | EventMcpBrowserOpenFailed1 + | EventCommandExecuted1 + | EventProjectUpdated1 + | EventSessionStatus1 + | EventSessionIdle1 + | EventQuestionAsked1 + | EventQuestionReplied1 + | EventQuestionRejected1 + | EventSessionCompacted1 + | EventVcsBranchUpdated1 + | EventWorkspaceReady1 + | EventWorkspaceFailed1 + | EventWorkspaceStatus1 + | EventWorktreeReady1 + | EventWorktreeFailed1 + | EventServerConnected1 + | EventGlobalDisposed1 + | EventGlobalConfigUpdated1 | EventServerInstanceDisposed | EventSessionTurnOpen | EventSessionTurnClose @@ -35,11 +124,8 @@ export type Event = | EventIndexingStatus | EventIndexingWarning | EventModelsDevRefreshed - | EventServerConnected - | EventGlobalDisposed - | EventGlobalConfigUpdated - | EventPluginAdded | EventIntegrationUpdated + | EventIntegrationConnectionUpdated | EventCatalogUpdated | EventSessionCreated | EventSessionUpdated @@ -53,8 +139,6 @@ export type Event = | EventSessionNextMoved | EventSessionNextPrompted | EventSessionNextPromptAdmitted - | EventSessionNextPromptPromoted - | EventSessionNextInterruptRequested | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextShellStarted @@ -79,24 +163,19 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed + | EventSessionNextRevertStaged + | EventSessionNextRevertCleared + | EventSessionNextRevertCommitted | EventMessagePartDelta | EventSessionDiff | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable | EventFileEdited + | EventReferenceUpdated | EventPermissionV2Asked | EventPermissionV2Replied - | EventReferenceUpdated + | EventPluginAdded | EventProjectDirectoriesUpdated | EventFileWatcherUpdated | EventPtyCreated @@ -110,17 +189,29 @@ export type Event = | EventLspUpdated | EventPermissionAsked | EventPermissionReplied - | EventSessionStatus - | EventSessionIdle - | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow22 + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed | EventCommandExecuted | EventProjectUpdated + | EventSessionStatus + | EventSessionIdle + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventSessionCompacted | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed + | EventServerConnected + | EventGlobalDisposed + | EventGlobalConfigUpdated export type QuestionReplied = { sessionID: string @@ -439,7 +530,6 @@ export type Session = { partID?: string snapshot?: string diff?: string - workspace?: "restored" | "snapshots-disabled" | "unavailable" } } @@ -483,6 +573,8 @@ export type UserMessage = { [key: string]: boolean } editorContext?: { + directory?: string + worktree?: string visibleFiles?: Array openTabs?: Array activeFile?: string @@ -780,9 +872,6 @@ export type StepStartPart = { messageID: string type: "step-start" snapshot?: string - time?: { - start: number - } } export type StepFinishPart = { @@ -893,46 +982,33 @@ export type Prompt = { agents?: Array } -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string - labelKey?: string - descriptionKey?: string - mode?: string +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number + exitCode?: number + sessionID?: string | null } -export type QuestionInfo = { +export type Todo = { /** - * Complete question + * Brief description of the task */ - question: string + content: string /** - * Very short label (max 30 chars) + * Current status of the task: pending, in_progress, completed, cancelled */ - header: string + status: string /** - * Available choices + * Priority level of the task: high, medium, low */ - options: Array - multiple?: boolean - questionKey?: string - headerKey?: string - custom?: boolean + priority: string } -export type QuestionTool = { - messageID: string - callID: string -} - -export type QuestionAnswer = Array - export type EventTuiPromptAppend = { id: string type: "tui.prompt.append" @@ -988,57 +1064,6 @@ export type EventTuiSessionSelect = { } } -export type AgentRequirementError = { - name: "AgentRequirementError" - data: { - message: string - agent: string - directory: string - state: "blocked" | "error" - skills: Array<{ - name: string - status: "ready" | "missing" | "error" - message?: string - }> - mcps: Array<{ - name: string - status: "ready" | "missing" | "error" - message?: string - }> - vscode_extensions: Array<{ - name: string - id: string - }> - } -} - -export type Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number - exitCode?: number - sessionID?: string | null -} - -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - export type SessionStatus = | { type: "idle" @@ -1066,6 +1091,46 @@ export type SessionStatus = message: string } +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string + labelKey?: string + descriptionKey?: string + mode?: string +} + +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + multiple?: boolean + questionKey?: string + headerKey?: string + custom?: boolean +} + +export type QuestionTool = { + messageID: string + callID: string +} + +export type QuestionAnswer = Array + export type GlobalEvent = { directory: string project?: string @@ -1101,11 +1166,8 @@ export type GlobalEvent = { | EventIndexingStatus | EventIndexingWarning | EventModelsDevRefreshed - | EventServerConnected - | EventGlobalDisposed - | EventGlobalConfigUpdated - | EventPluginAdded | EventIntegrationUpdated + | EventIntegrationConnectionUpdated | EventCatalogUpdated | EventSessionCreated | EventSessionUpdated @@ -1119,8 +1181,6 @@ export type GlobalEvent = { | EventSessionNextMoved | EventSessionNextPrompted | EventSessionNextPromptAdmitted - | EventSessionNextPromptPromoted - | EventSessionNextInterruptRequested | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextShellStarted @@ -1145,24 +1205,19 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed + | EventSessionNextRevertStaged + | EventSessionNextRevertCleared + | EventSessionNextRevertCommitted | EventMessagePartDelta | EventSessionDiff | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable | EventFileEdited + | EventReferenceUpdated | EventPermissionV2Asked | EventPermissionV2Replied - | EventReferenceUpdated + | EventPluginAdded | EventProjectDirectoriesUpdated | EventFileWatcherUpdated | EventPtyCreated @@ -1176,17 +1231,906 @@ export type GlobalEvent = { | EventLspUpdated | EventPermissionAsked | EventPermissionReplied - | EventSessionStatus - | EventSessionIdle - | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed | EventCommandExecuted | EventProjectUpdated + | EventSessionStatus + | EventSessionIdle + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventSessionCompacted | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed + | EventServerConnected + | EventGlobalDisposed + | EventGlobalConfigUpdated + | { + id: string + type: "models-dev.refreshed" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "integration.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "integration.connection.updated" + properties: { + integrationID: string + } + } + | { + id: string + type: "catalog.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } + } + | { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } + } + | { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } + } + | { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } + } + | { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } + } + | { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } + } + | { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } + } + | { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + messageID: string + agent: string + } + } + | { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } + } + | { + id: string + type: "session.next.moved" + properties: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } + } + | { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } + | { + id: string + type: "session.next.prompt.admitted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } + | { + id: string + type: "session.next.context.updated" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } + | { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } + | { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } + } + | { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } + } + | { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } + } + | { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } + } + | { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } + } + | { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } + } + | { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + delta: string + } + } + | { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } + } + | { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } + } + | { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + delta: string + } + } + | { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } + } + | { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } + } + | { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + delta: string + } + } + | { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } + } + | { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } + } + | { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } + } + | { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } + } + | { + id: string + type: "session.next.tool.failed" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } + } + | { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } + } + | { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } + } + | { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } + | { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + include?: string + } + } + | { + id: string + type: "session.next.revert.staged" + properties: { + timestamp: number + sessionID: string + revert: RevertState + } + } + | { + id: string + type: "session.next.revert.cleared" + properties: { + timestamp: number + sessionID: string + } + } + | { + id: string + type: "session.next.revert.committed" + properties: { + timestamp: number + sessionID: string + messageID: string + } + } + | { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } + } + | { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } + } + | { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError + } + } + | { + id: string + type: "installation.updated" + properties: { + version: string + } + } + | { + id: string + type: "installation.update-available" + properties: { + version: string + } + } + | { + id: string + type: "file.edited" + properties: { + file: string + } + } + | { + id: string + type: "reference.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "permission.v2.asked" + properties: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } + } + | { + id: string + type: "permission.v2.replied" + properties: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } + } + | { + id: string + type: "plugin.added" + properties: { + id: string + } + } + | { + id: string + type: "project.directories.updated" + properties: { + projectID: string + } + } + | { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } + } + | { + id: string + type: "pty.created" + properties: { + info: Pty + } + } + | { + id: string + type: "pty.updated" + properties: { + info: Pty + } + } + | { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } + } + | { + id: string + type: "pty.deleted" + properties: { + id: string + } + } + | { + id: string + type: "question.v2.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } + } + | { + id: string + type: "question.v2.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } + } + | { + id: string + type: "question.v2.rejected" + properties: { + sessionID: string + requestID: string + } + } + | { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } + } + | { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "permission.asked" + properties: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } + } + | { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } + } + | { + id: string + type: "tui.prompt.append" + properties: { + text: string + } + } + | { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } + } + | { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } + } + | { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } + } + | { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } + } + | { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } + } + | { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } + } + | { + id: string + type: "project.updated" + properties: { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array + } + } + | { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } + } + | { + id: string + type: "session.idle" + properties: { + sessionID: string + } + } + | { + id: string + type: "question.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + blocking?: boolean + tool?: QuestionTool + } + } + | { + id: string + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } + } + | { + id: string + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } + } + | { + id: string + type: "session.compacted" + properties: { + sessionID: string + } + } + | { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } + } + | { + id: string + type: "workspace.ready" + properties: { + name: string + } + } + | { + id: string + type: "workspace.failed" + properties: { + message: string + } + } + | { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } + } + | { + id: string + type: "worktree.ready" + properties: { + name: string + branch?: string + } + } + | { + id: string + type: "worktree.failed" + properties: { + message: string + } + } + | { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } + } + | { + id: string + type: "global.config.updated" + properties: { + [key: string]: unknown + } + } | SyncEventSessionCreated | SyncEventSessionUpdated | SyncEventSessionDeleted @@ -1199,7 +2143,6 @@ export type GlobalEvent = { | SyncEventSessionNextMoved | SyncEventSessionNextPrompted | SyncEventSessionNextPromptAdmitted - | SyncEventSessionNextPromptPromoted | SyncEventSessionNextContextUpdated | SyncEventSessionNextSynthetic | SyncEventSessionNextShellStarted @@ -1220,6 +2163,9 @@ export type GlobalEvent = { | SyncEventSessionNextRetried | SyncEventSessionNextCompactionStarted | SyncEventSessionNextCompactionEnded + | SyncEventSessionNextRevertStaged + | SyncEventSessionNextRevertCleared + | SyncEventSessionNextRevertCommitted } /** @@ -1609,6 +2555,7 @@ export type Config = { terminal_command_display?: "expanded" | "collapsed" code_edit_display?: "expanded" | "collapsed" hide_prompt_training_models?: boolean + privacy_mode?: boolean /** * Sandbox configuration for agent tools */ @@ -2212,24 +3159,11 @@ export type McpServerNotFoundError = { export type Project = { id: string worktree: string - vcs?: "git" + vcs?: ProjectVcs name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime sandboxes: Array } @@ -3534,24 +4468,79 @@ export type InvalidCursorError = { message: string } +export type SessionActive = { + type: "running" +} + export type SessionNotFoundError = { _tag: "SessionNotFoundError" sessionID: string message: string } +export type PromptInput = { + text: string + files?: Array + agents?: Array +} + export type ServiceUnavailableError = { _tag: "ServiceUnavailableError" message: string service?: string } +export type MessageNotFoundError = { + _tag: "MessageNotFoundError" + sessionID: string + messageID: string + message: string +} + export type UnknownError1 = { _tag: "UnknownError" message: string ref?: string } +export type SessionDurableEvent = + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextEnded + | SessionNextToolInputStarted + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextReasoningStarted + | SessionNextReasoningEnded + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + | SessionNextPromptPromoted + +export type SessionHistory = { + data: Array + hasMore: boolean +} + +export type SessionDurableEventStream = string + export type SessionMessagesResponse = { data: Array cursor: { @@ -3566,6 +4555,164 @@ export type ProviderNotFoundError = { message: string } +export type OutputFormat1 = + | { + type: "text" + } + | { + type: "json_schema" + schema: JsonSchema + retryCount?: number + } + +export type SessionStatus2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + status: SessionStatus + } +} + +export type QuestionReplied2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type QuestionRejected2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.rejected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type V2Event = + | ModelsDevRefreshed + | IntegrationUpdated + | IntegrationConnectionUpdated + | CatalogUpdated + | SessionCreated + | SessionUpdated + | SessionDeleted + | MessageUpdated + | MessageRemoved + | MessagePartUpdated + | MessagePartRemoved + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextDelta + | SessionNextTextEnded + | SessionNextReasoningStarted + | SessionNextReasoningDelta + | SessionNextReasoningEnded + | SessionNextToolInputStarted + | SessionNextToolInputDelta + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionDelta + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + | MessagePartDelta + | SessionDiff + | SessionError + | InstallationUpdated + | InstallationUpdateAvailable + | FileEdited + | ReferenceUpdated + | PermissionV2Asked + | PermissionV2Replied + | PluginAdded + | ProjectDirectoriesUpdated + | FileWatcherUpdated + | PtyCreated + | PtyUpdated + | PtyExited + | PtyDeleted + | QuestionV2Asked + | QuestionV2Replied + | QuestionV2Rejected + | TodoUpdated + | LspUpdated + | PermissionAsked + | PermissionReplied + | TuiPromptAppend + | TuiCommandExecute + | TuiToastShow + | TuiSessionSelect + | McpToolsChanged + | McpBrowserOpenFailed + | CommandExecuted + | ProjectUpdated + | SessionStatus2 + | SessionIdle + | QuestionAsked + | QuestionReplied2 + | QuestionRejected2 + | SessionCompacted + | VcsBranchUpdated + | WorkspaceReady + | WorkspaceFailed + | WorkspaceStatus + | WorktreeReady + | WorktreeFailed + | ServerConnected + | GlobalDisposed + | GlobalConfigUpdated + +export type V2EventStream = string + export type ForbiddenError = { _tag: "ForbiddenError" message: string @@ -3603,6 +4750,21 @@ export type InteractiveTerminalInfo1 = { } } +export type CredentialValue = CredentialOAuth | CredentialKey + +export type IntegrationInputs = { + [key: string]: string +} + +export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod + +export type IntegrationRef = { + id: string + name: string +} + +export type SkillV2Source = SkillV2DirectorySource | SkillV2UrlSource | SkillV2EmbeddedSource + export type MoveSessionDestination = { directory: string } @@ -3977,38 +5139,6 @@ export type EventModelsDevRefreshed = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalConfigUpdated = { - id: string - type: "global.config.updated" - properties: { - [key: string]: unknown - } -} - -export type EventPluginAdded = { - id: string - type: "plugin.added" - properties: { - id: string - } -} - export type EventIntegrationUpdated = { id: string type: "integration.updated" @@ -4017,6 +5147,14 @@ export type EventIntegrationUpdated = { } } +export type EventIntegrationConnectionUpdated = { + id: string + type: "integration.connection.updated" + properties: { + integrationID: string + } +} + export type EventCatalogUpdated = { id: string type: "catalog.updated" @@ -4101,6 +5239,12 @@ export type EventSessionNextAgentSwitched = { } } +export type ModelRef = { + id: string + providerID: string + variant?: string +} + export type EventSessionNextModelSwitched = { id: string type: "session.next.model.switched" @@ -4108,11 +5252,7 @@ export type EventSessionNextModelSwitched = { timestamp: number sessionID: string messageID: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } } @@ -4175,27 +5315,6 @@ export type EventSessionNextPromptAdmitted = { } } -export type EventSessionNextPromptPromoted = { - id: string - type: "session.next.prompt.promoted" - properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } -} - -export type EventSessionNextInterruptRequested = { - id: string - type: "session.next.interrupt.requested" - properties: { - timestamp: number - sessionID: string - } -} - export type EventSessionNextContextUpdated = { id: string type: "session.next.context.updated" @@ -4249,11 +5368,7 @@ export type EventSessionNextStepStarted = { sessionID: string assistantMessageID: string agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef snapshot?: string } } @@ -4277,6 +5392,7 @@ export type EventSessionNextStepEnded = { } } snapshot?: string + files?: Array } } @@ -4331,6 +5447,12 @@ export type EventSessionNextTextEnded = { } } +export type LlmProviderMetadata = { + [key: string]: { + [key: string]: unknown + } +} + export type EventSessionNextReasoningStarted = { id: string type: "session.next.reasoning.started" @@ -4339,11 +5461,7 @@ export type EventSessionNextReasoningStarted = { sessionID: string assistantMessageID: string reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } @@ -4368,11 +5486,7 @@ export type EventSessionNextReasoningEnded = { assistantMessageID: string reasoningID: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } @@ -4426,11 +5540,7 @@ export type EventSessionNextToolCalled = { } provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -4447,6 +5557,8 @@ export type ToolFileContent = { name?: string } +export type LlmToolContent = ToolTextContent | ToolFileContent + export type EventSessionNextToolProgress = { id: string type: "session.next.tool.progress" @@ -4458,7 +5570,7 @@ export type EventSessionNextToolProgress = { structured: { [key: string]: unknown } - content: Array + content: Array } } @@ -4473,16 +5585,12 @@ export type EventSessionNextToolSuccess = { structured: { [key: string]: unknown } - content: Array + content: Array outputPaths?: Array result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -4499,11 +5607,7 @@ export type EventSessionNextToolFailed = { result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -4560,62 +5664,56 @@ export type EventSessionNextCompactionEnded = { properties: { timestamp: number sessionID: string - messageID?: string - reason?: "auto" | "manual" + messageID: string + reason: "auto" | "manual" text: string - recent?: string + recent: string include?: string } } -export type EventQuestionAsked = { +export type FileDiff = { + path: string + status: "added" | "modified" | "deleted" + additions: number + deletions: number + patch: string +} + +export type RevertState = { + messageID: string + partID?: string + snapshot?: string + diff?: string + files?: Array +} + +export type EventSessionNextRevertStaged = { id: string - type: "question.asked" + type: "session.next.revert.staged" properties: { - id: string + timestamp: number sessionID: string - /** - * Questions to ask - */ - questions: Array - blocking?: boolean - tool?: QuestionTool + revert: RevertState } } -export type EventQuestionReplied = { +export type EventSessionNextRevertCleared = { id: string - type: "question.replied" + type: "session.next.revert.cleared" properties: { + timestamp: number sessionID: string - requestID: string - answers: Array } } -export type EventQuestionRejected = { +export type EventSessionNextRevertCommitted = { id: string - type: "question.rejected" + type: "session.next.revert.committed" properties: { + timestamp: number sessionID: string - requestID: string - } -} - -export type EventMcpToolsChanged = { - id: string - type: "mcp.tools.changed" - properties: { - server: string - } -} - -export type EventMcpBrowserOpenFailed = { - id: string - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string + messageID: string } } @@ -4654,7 +5752,6 @@ export type EventSessionError = { | ContextOverflowError | ContentFilterError | ApiError - | AgentRequirementError } } @@ -4682,6 +5779,14 @@ export type EventFileEdited = { } } +export type EventReferenceUpdated = { + id: string + type: "reference.updated" + properties: { + [key: string]: unknown + } +} + export type PermissionV2Source = { type: "tool" messageID: string @@ -4716,11 +5821,11 @@ export type EventPermissionV2Replied = { } } -export type EventReferenceUpdated = { +export type EventPluginAdded = { id: string - type: "reference.updated" + type: "plugin.added" properties: { - [key: string]: unknown + id: string } } @@ -4888,6 +5993,70 @@ export type EventPermissionReplied = { } } +export type EventMcpToolsChanged = { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type ProjectVcs = "git" + +export type ProjectIcon = { + url?: string + override?: string + color?: string +} + +export type ProjectCommands = { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string +} + +export type ProjectTime = { + created: number + updated: number + initialized?: number +} + +export type EventProjectUpdated = { + id: string + type: "project.updated" + properties: { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array + } +} + export type EventSessionStatus = { id: string type: "session.status" @@ -4905,6 +6074,40 @@ export type EventSessionIdle = { } } +export type EventQuestionAsked = { + id: string + type: "question.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + blocking?: boolean + tool?: QuestionTool + } +} + +export type EventQuestionReplied = { + id: string + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionRejected = { + id: string + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + export type EventSessionCompacted = { id: string type: "session.compacted" @@ -4913,45 +6116,6 @@ export type EventSessionCompacted = { } } -export type EventCommandExecuted = { - id: string - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - -export type EventProjectUpdated = { - id: string - type: "project.updated" - properties: { - id: string - worktree: string - vcs?: "git" - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } - sandboxes: Array - } -} - export type EventVcsBranchUpdated = { id: string type: "vcs.branch.updated" @@ -5002,6 +6166,30 @@ export type EventWorktreeFailed = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalConfigUpdated = { + id: string + type: "global.config.updated" + properties: { + [key: string]: unknown + } +} + export type SyncEventSessionCreated = { type: "sync" id: string @@ -5138,11 +6326,7 @@ export type SyncEventSessionNextModelSwitched = { timestamp: number sessionID: string messageID: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } } } @@ -5200,24 +6384,6 @@ export type SyncEventSessionNextPromptAdmitted = { } } -export type SyncEventSessionNextPromptPromoted = { - type: "sync" - id: string - syncEvent: { - type: "session.next.prompt.promoted.1" - id: string - seq: number - aggregateID: string - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } - } -} - export type SyncEventSessionNextContextUpdated = { type: "sync" id: string @@ -5300,11 +6466,7 @@ export type SyncEventSessionNextStepStarted = { sessionID: string assistantMessageID: string agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef snapshot?: string } } @@ -5334,6 +6496,7 @@ export type SyncEventSessionNextStepEnded = { } } snapshot?: string + files?: Array } } } @@ -5403,11 +6566,7 @@ export type SyncEventSessionNextReasoningStarted = { sessionID: string assistantMessageID: string reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } } @@ -5426,11 +6585,7 @@ export type SyncEventSessionNextReasoningEnded = { assistantMessageID: string reasoningID: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + providerMetadata?: LlmProviderMetadata } } } @@ -5490,11 +6645,7 @@ export type SyncEventSessionNextToolCalled = { } provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -5516,7 +6667,7 @@ export type SyncEventSessionNextToolProgress = { structured: { [key: string]: unknown } - content: Array + content: Array } } } @@ -5537,16 +6688,12 @@ export type SyncEventSessionNextToolSuccess = { structured: { [key: string]: unknown } - content: Array + content: Array outputPaths?: Array result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -5569,11 +6716,7 @@ export type SyncEventSessionNextToolFailed = { result?: unknown provider: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata } } } @@ -5624,15 +6767,62 @@ export type SyncEventSessionNextCompactionEnded = { data: { timestamp: number sessionID: string - messageID?: string - reason?: "auto" | "manual" + messageID: string + reason: "auto" | "manual" text: string - recent?: string + recent: string include?: string } } } +export type SyncEventSessionNextRevertStaged = { + type: "sync" + id: string + syncEvent: { + type: "session.next.revert.staged.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + revert: RevertState + } + } +} + +export type SyncEventSessionNextRevertCleared = { + type: "sync" + id: string + syncEvent: { + type: "session.next.revert.cleared.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + } + } +} + +export type SyncEventSessionNextRevertCommitted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.revert.committed.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + } + } +} + export type ConfigV2ReferenceGit = { repository: string branch?: string @@ -5659,6 +6849,16 @@ export type ProjectDirectories = Array<{ strategy?: string }> +export type PtyTicketConnectToken = { + ticket: string + expires_in: number +} + +export type WorkspaceEventConnectionStatus = { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" +} + export type LocationInfo = { directory: string workspaceID?: string @@ -5668,6 +6868,17 @@ export type LocationInfo = { } } +export type ProviderRequest = { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } +} + +export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + export type PermissionV2Effect = "allow" | "deny" | "ask" export type PermissionV2Rule = { @@ -5680,24 +6891,13 @@ export type PermissionV2Ruleset = Array export type AgentV2Info = { id: string - model?: { - id: string - providerID: string - variant?: string - } - request: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - } + model?: ModelRef + request: ProviderRequest system?: string description?: string mode: "subagent" | "primary" | "all" hidden: boolean - color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + color?: AgentColor steps?: number permissions: PermissionV2Ruleset } @@ -5707,11 +6907,7 @@ export type SessionV2Info = { parentID?: string projectID: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef cost: number tokens: { input: number @@ -5730,6 +6926,14 @@ export type SessionV2Info = { title: string location: LocationRef subpath?: string + revert?: RevertState +} + +export type PromptInputFileAttachment = { + uri: string + name?: string + description?: string + source?: PromptSource } export type SessionInputAdmitted = { @@ -5763,11 +6967,7 @@ export type SessionMessageModelSwitched = { created: number } type: "model-switched" - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef } export type SessionMessageUser = { @@ -5834,10 +7034,10 @@ export type SessionMessageAssistantReasoning = { type: "reasoning" id: string text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown - } + providerMetadata?: LlmProviderMetadata + time?: { + created: number + completed?: number } } @@ -5854,7 +7054,7 @@ export type SessionMessageToolStateRunning = { structured: { [key: string]: unknown } - content: Array + content: Array } export type SessionMessageToolStateCompleted = { @@ -5863,7 +7063,7 @@ export type SessionMessageToolStateCompleted = { [key: string]: unknown } attachments?: Array - content: Array + content: Array outputPaths?: Array structured: { [key: string]: unknown @@ -5876,7 +7076,7 @@ export type SessionMessageToolStateError = { input: { [key: string]: unknown } - content: Array + content: Array structured: { [key: string]: unknown } @@ -5890,16 +7090,8 @@ export type SessionMessageAssistantTool = { name: string provider?: { executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown - } - } - resultMetadata?: { - [key: string]: { - [key: string]: unknown - } - } + metadata?: LlmProviderMetadata + resultMetadata?: LlmProviderMetadata } state: | SessionMessageToolStatePending @@ -5925,15 +7117,12 @@ export type SessionMessageAssistant = { } type: "assistant" agent: string - model: { - id: string - providerID: string - variant?: string - } + model: ModelRef content: Array snapshot?: { start?: string end?: string + files?: Array } finish?: string cost?: number @@ -5973,34 +7162,682 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction +export type SessionNextAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.agent.switched" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + agent: string + } +} + +export type SessionNextModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.model.switched" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } +} + +export type SessionNextMoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.moved" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } +} + +export type SessionNextPrompted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextPromptAdmitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompt.admitted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextContextUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.context.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.synthetic" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextShellStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } +} + +export type SessionNextShellEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SessionNextStepStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } +} + +export type SessionNextStepEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } +} + +export type SessionNextStepFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } +} + +export type SessionNextTextStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } +} + +export type SessionNextTextEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } +} + +export type SessionNextToolInputStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type SessionNextToolInputEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type SessionNextToolCalled = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.called" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolProgress = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.progress" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SessionNextToolSuccess = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.success" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextReasoningStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionNextReasoningEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } +} + +export type SessionNextRetried = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.retried" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SessionNextCompactionStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.started" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } +} + +export type SessionNextCompactionEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.ended" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + include?: string + } +} + +export type SessionNextRevertStaged = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.staged" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + revert: RevertState + } +} + +export type SessionNextRevertCleared = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.cleared" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + } +} + +export type SessionNextRevertCommitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.revert.committed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + } +} + +export type SessionNextPromptPromoted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompt.promoted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + timeCreated: number + } +} + +export type ModelApi = + | { + id: string + type: "aisdk" + package: string + url?: string + settings?: { + [key: string]: unknown + } + } + | { + id: string + type: "native" + url?: string + settings: { + [key: string]: unknown + } + } + +export type ModelCapabilities = { + tools: boolean + input: Array + output: Array +} + +export type ModelCost = { + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } +} + export type ModelV2Info = { id: string providerID: string family?: string name: string - api: - | { - id: string - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - id: string - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - capabilities: { - tools: boolean - input: Array - output: Array - } + api: ModelApi + capabilities: ModelCapabilities request: { headers: { [key: string]: string @@ -6008,19 +7845,6 @@ export type ModelV2Info = { body: { [key: string]: unknown } - generation?: { - maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - stop?: Array - } - options?: { - [key: string]: unknown - } variant?: string } variants: Array<{ @@ -6031,35 +7855,11 @@ export type ModelV2Info = { body: { [key: string]: unknown } - generation?: { - maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - stop?: Array - } - options?: { - [key: string]: unknown - } }> time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + released: number } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> + cost: Array status: "alpha" | "beta" | "deprecated" | "active" enabled: boolean limit: { @@ -6069,34 +7869,32 @@ export type ModelV2Info = { } } +export type ProviderAisdk = { + type: "aisdk" + package: string + url?: string + settings?: { + [key: string]: unknown + } +} + +export type ProviderNative = { + type: "native" + url?: string + settings: { + [key: string]: unknown + } +} + +export type ProviderApi = ProviderAisdk | ProviderNative + export type ProviderV2Info = { id: string + integrationID?: string name: string disabled?: boolean - api: - | { - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - request: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - } + api: ProviderApi + request: ProviderRequest } export type IntegrationWhen = { @@ -6158,7 +7956,7 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo export type IntegrationInfo = { id: string name: string - methods: Array + methods: Array connections: Array } @@ -6173,6 +7971,37 @@ export type IntegrationAttempt = { } } +export type IntegrationAttemptStatus = + | { + status: "pending" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "complete" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "failed" + message: string + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "expired" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + export type PermissionV2Request = { id: string sessionID: string @@ -6195,7 +8024,6 @@ export type PermissionSavedInfo = { export type FileSystemEntry = { path: string type: "file" | "directory" - mime: string } export type CommandV2Info = { @@ -6203,11 +8031,7 @@ export type CommandV2Info = { template: string description?: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef variant?: string subtask?: boolean } @@ -6220,6 +8044,1108 @@ export type SkillV2Info = { content: string } +export type ModelsDevRefreshed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "models-dev.refreshed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type IntegrationUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "integration.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type IntegrationConnectionUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "integration.connection.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + integrationID: string + } +} + +export type CatalogUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "catalog.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type SessionCreated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.created" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type SessionUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type SessionDeleted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.deleted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type MessageUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + info: Message + } +} + +export type MessageRemoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.removed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + } +} + +export type MessagePartUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + part: Part + time: number + } +} + +export type MessagePartRemoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.removed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SessionNextTextDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + delta: string + } +} + +export type SessionNextReasoningDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + delta: string + } +} + +export type SessionNextToolInputDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + delta: string + } +} + +export type SessionNextCompactionDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.compaction.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type MessagePartDelta = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.part.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type SessionDiff = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.diff" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + diff: Array + } +} + +export type SessionError = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.error" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError + } +} + +export type InstallationUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "installation.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + version: string + } +} + +export type InstallationUpdateAvailable = { + id: string + metadata?: { + [key: string]: unknown + } + type: "installation.update-available" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + version: string + } +} + +export type FileEdited = { + id: string + metadata?: { + [key: string]: unknown + } + type: "file.edited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + file: string + } +} + +export type ReferenceUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "reference.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type PermissionV2Asked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.v2.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } +} + +export type PermissionV2Replied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.v2.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } +} + +export type PluginAdded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "plugin.added" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + } +} + +export type ProjectDirectoriesUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "project.directories.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + projectID: string + } +} + +export type FileWatcherUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "file.watcher.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type PtyCreated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.created" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + info: Pty + } +} + +export type PtyUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + info: Pty + } +} + +export type PtyExited = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.exited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + exitCode: number + } +} + +export type PtyDeleted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "pty.deleted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + } +} + +export type QuestionV2Asked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } +} + +export type QuestionV2Replied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type QuestionV2Rejected = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.v2.rejected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type TodoUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "todo.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + todos: Array + } +} + +export type LspUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "lsp.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type PermissionAsked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } +} + +export type PermissionReplied = { + id: string + metadata?: { + [key: string]: unknown + } + type: "permission.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type TuiPromptAppend = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.prompt.append" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + text: string + } +} + +export type TuiCommandExecute = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.command.execute" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type TuiToastShow = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.toast.show" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type TuiSessionSelect = { + id: string + metadata?: { + [key: string]: unknown + } + type: "tui.session.select" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type McpToolsChanged = { + id: string + metadata?: { + [key: string]: unknown + } + type: "mcp.tools.changed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + server: string + } +} + +export type McpBrowserOpenFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "mcp.browser.open.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + mcpName: string + url: string + } +} + +export type CommandExecuted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "command.executed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type ProjectUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "project.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array + } +} + +export type SessionIdle = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.idle" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type QuestionAsked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + blocking?: boolean + tool?: QuestionTool + } +} + +export type SessionCompacted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.compacted" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + } +} + +export type VcsBranchUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "vcs.branch.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + branch?: string + } +} + +export type WorkspaceReady = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + } +} + +export type WorkspaceFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + message: string + } +} + +export type WorkspaceStatus = { + id: string + metadata?: { + [key: string]: unknown + } + type: "workspace.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type WorktreeReady = { + id: string + metadata?: { + [key: string]: unknown + } + type: "worktree.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + branch?: string + } +} + +export type WorktreeFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "worktree.failed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + message: string + } +} + +export type ServerConnected = { + id: string + metadata?: { + [key: string]: unknown + } + type: "server.connected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type GlobalDisposed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "global.disposed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type GlobalConfigUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "global.config.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type QuestionV2Request = { id: string sessionID: string @@ -6252,18 +9178,986 @@ export type ReferenceGitSource = { hidden?: boolean } +export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource + export type ReferenceInfo = { name: string path: string description?: string hidden?: boolean - source: ReferenceLocalSource | ReferenceGitSource + source: ReferenceSource } export type ProjectCopyCopy = { directory: string } +export type EventModelsDevRefreshed1 = { + id: string + type: "models-dev.refreshed" + properties: { + [key: string]: unknown + } +} + +export type EventIntegrationUpdated1 = { + id: string + type: "integration.updated" + properties: { + [key: string]: unknown + } +} + +export type EventIntegrationConnectionUpdated1 = { + id: string + type: "integration.connection.updated" + properties: { + integrationID: string + } +} + +export type EventCatalogUpdated1 = { + id: string + type: "catalog.updated" + properties: { + [key: string]: unknown + } +} + +export type EventSessionCreated1 = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated1 = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted1 = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + +export type EventMessageUpdated1 = { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } +} + +export type EventMessageRemoved1 = { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated1 = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved1 = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionNextAgentSwitched1 = { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + messageID: string + agent: string + } +} + +export type EventSessionNextModelSwitched1 = { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } +} + +export type EventSessionNextMoved1 = { + id: string + type: "session.next.moved" + properties: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } +} + +export type EventSessionNextPrompted1 = { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type EventSessionNextPromptAdmitted1 = { + id: string + type: "session.next.prompt.admitted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type EventSessionNextContextUpdated1 = { + id: string + type: "session.next.context.updated" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type EventSessionNextSynthetic1 = { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type EventSessionNextShellStarted1 = { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } +} + +export type EventSessionNextShellEnded1 = { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type EventSessionNextStepStarted1 = { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } +} + +export type EventSessionNextStepEnded1 = { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } +} + +export type EventSessionNextStepFailed1 = { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } +} + +export type EventSessionNextTextStarted1 = { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } +} + +export type EventSessionNextTextDelta1 = { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + delta: string + } +} + +export type EventSessionNextTextEnded1 = { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } +} + +export type EventSessionNextReasoningStarted1 = { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } +} + +export type EventSessionNextReasoningDelta1 = { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded1 = { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata + } +} + +export type EventSessionNextToolInputStarted1 = { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type EventSessionNextToolInputDelta1 = { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + delta: string + } +} + +export type EventSessionNextToolInputEnded1 = { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type EventSessionNextToolCalled1 = { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type EventSessionNextToolProgress1 = { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type EventSessionNextToolSuccess1 = { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type EventSessionNextToolFailed1 = { + id: string + type: "session.next.tool.failed" + properties: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type EventSessionNextRetried1 = { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type EventSessionNextCompactionStarted1 = { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + } +} + +export type EventSessionNextCompactionDelta1 = { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type EventSessionNextCompactionEnded1 = { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string + include?: string + } +} + +export type EventSessionNextRevertStaged1 = { + id: string + type: "session.next.revert.staged" + properties: { + timestamp: number + sessionID: string + revert: RevertState + } +} + +export type EventSessionNextRevertCleared1 = { + id: string + type: "session.next.revert.cleared" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextRevertCommitted1 = { + id: string + type: "session.next.revert.committed" + properties: { + timestamp: number + sessionID: string + messageID: string + } +} + +export type EventMessagePartDelta1 = { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type EventSessionDiff1 = { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError1 = { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ContentFilterError + | ApiError + } +} + +export type EventInstallationUpdated1 = { + id: string + type: "installation.updated" + properties: { + version: string + } +} + +export type EventInstallationUpdateAvailable1 = { + id: string + type: "installation.update-available" + properties: { + version: string + } +} + +export type EventFileEdited1 = { + id: string + type: "file.edited" + properties: { + file: string + } +} + +export type EventReferenceUpdated1 = { + id: string + type: "reference.updated" + properties: { + [key: string]: unknown + } +} + +export type EventPermissionV2Asked1 = { + id: string + type: "permission.v2.asked" + properties: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } +} + +export type EventPermissionV2Replied1 = { + id: string + type: "permission.v2.replied" + properties: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } +} + +export type EventPluginAdded1 = { + id: string + type: "plugin.added" + properties: { + id: string + } +} + +export type EventProjectDirectoriesUpdated1 = { + id: string + type: "project.directories.updated" + properties: { + projectID: string + } +} + +export type EventFileWatcherUpdated1 = { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type EventPtyCreated1 = { + id: string + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated1 = { + id: string + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited1 = { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted1 = { + id: string + type: "pty.deleted" + properties: { + id: string + } +} + +export type EventQuestionV2Asked1 = { + id: string + type: "question.v2.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: QuestionV2Tool + } +} + +export type EventQuestionV2Replied1 = { + id: string + type: "question.v2.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionV2Rejected1 = { + id: string + type: "question.v2.rejected" + properties: { + sessionID: string + requestID: string + } +} + +export type EventTodoUpdated1 = { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventLspUpdated1 = { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } +} + +export type EventPermissionAsked1 = { + id: string + type: "permission.asked" + properties: { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } + } +} + +export type EventPermissionReplied1 = { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type EventTuiPromptAppend1 = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute1 = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow1 = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect1 = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type EventMcpToolsChanged1 = { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed1 = { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted1 = { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type EventProjectUpdated1 = { + id: string + type: "project.updated" + properties: { + id: string + worktree: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array + } +} + +export type EventSessionStatus1 = { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle1 = { + id: string + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventQuestionAsked1 = { + id: string + type: "question.asked" + properties: { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + blocking?: boolean + tool?: QuestionTool + } +} + +export type EventQuestionReplied1 = { + id: string + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionRejected1 = { + id: string + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + +export type EventSessionCompacted1 = { + id: string + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventVcsBranchUpdated1 = { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + +export type EventWorkspaceReady1 = { + id: string + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed1 = { + id: string + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceStatus1 = { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventWorktreeReady1 = { + id: string + type: "worktree.ready" + properties: { + name: string + branch?: string + } +} + +export type EventWorktreeFailed1 = { + id: string + type: "worktree.failed" + properties: { + message: string + } +} + +export type EventServerConnected1 = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed1 = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalConfigUpdated1 = { + id: string + type: "global.config.updated" + properties: { + [key: string]: unknown + } +} + export type EventMemoryStatus1 = { id: string type: "memory.status" @@ -6375,7 +10269,7 @@ export type EventMemoryError1 = { } } -export type EventTuiToastShow1 = { +export type EventTuiToastShow22 = { id: string type: "tui.toast.show" properties: { @@ -6386,6 +10280,40 @@ export type EventTuiToastShow1 = { } } +export type CredentialOAuth = { + type: "oauth" + methodID: string + refresh: string + access: string + expires: number + metadata?: { + [key: string]: unknown + } +} + +export type CredentialKey = { + type: "key" + key: string + metadata?: { + [key: string]: unknown + } +} + +export type SkillV2DirectorySource = { + type: "directory" + path: string +} + +export type SkillV2UrlSource = { + type: "url" + url: string +} + +export type SkillV2EmbeddedSource = { + type: "embedded" + skill: SkillV2Info +} + export type BadRequestError = { name: "BadRequest" data: { @@ -8213,17 +12141,8 @@ export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGi export type ProjectUpdateData = { body?: { name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } + icon?: ProjectIcon + commands?: ProjectCommands } path: { projectID: string @@ -8490,11 +12409,11 @@ export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses] export type PtyUpdateData = { body?: { title?: string - sessionID?: string | null size?: { rows: number cols: number } + sessionID?: string | null } path: { ptyID: string @@ -8561,10 +12480,7 @@ export type PtyConnectTokenResponses = { /** * WebSocket connect token */ - 200: { - ticket: string - expires_in: number - } + 200: PtyTicketConnectToken } export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses] @@ -9324,6 +13240,8 @@ export type SessionPromptData = { variant?: string snapshotInitialization?: "wait" editorContext?: { + directory?: string + worktree?: string visibleFiles?: Array openTabs?: Array activeFile?: string @@ -9678,6 +13596,8 @@ export type SessionPromptAsyncData = { variant?: string snapshotInitialization?: "wait" editorContext?: { + directory?: string + worktree?: string visibleFiles?: Array openTabs?: Array activeFile?: string @@ -10718,10 +14638,7 @@ export type ExperimentalWorkspaceStatusResponses = { /** * Workspace status */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - }> + 200: Array } export type ExperimentalWorkspaceStatusResponse = @@ -14587,11 +18504,7 @@ export type V2SessionCreateData = { body: { id?: string agent?: string - model?: { - id: string - providerID: string - variant?: string - } + model?: ModelRef location?: LocationRef } path?: never @@ -14623,6 +18536,39 @@ export type V2SessionCreateResponses = { export type V2SessionCreateResponse = V2SessionCreateResponses[keyof V2SessionCreateResponses] +export type V2SessionActiveData = { + body?: never + path?: never + query?: never + url: "/api/session/active" +} + +export type V2SessionActiveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SessionActiveError = V2SessionActiveErrors[keyof V2SessionActiveErrors] + +export type V2SessionActiveResponses = { + /** + * Success + */ + 200: { + data: { + [key: string]: unknown | SessionActive + } + } +} + +export type V2SessionActiveResponse = V2SessionActiveResponses[keyof V2SessionActiveResponses] + export type V2SessionGetData = { body?: never path: { @@ -14660,10 +18606,84 @@ export type V2SessionGetResponses = { export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses] +export type V2SessionSwitchAgentData = { + body: { + agent: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/agent" +} + +export type V2SessionSwitchAgentErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchAgentError = V2SessionSwitchAgentErrors[keyof V2SessionSwitchAgentErrors] + +export type V2SessionSwitchAgentResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V2SessionSwitchAgentResponses] + +export type V2SessionSwitchModelData = { + body: { + model: ModelRef + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/model" +} + +export type V2SessionSwitchModelErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchModelError = V2SessionSwitchModelErrors[keyof V2SessionSwitchModelErrors] + +export type V2SessionSwitchModelResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses] + export type V2SessionPromptData = { body: { id?: string - prompt: Prompt + prompt: PromptInput delivery?: "steer" | "queue" resume?: boolean } @@ -14784,6 +18804,124 @@ export type V2SessionWaitResponses = { export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] +export type V2SessionRevertStageData = { + body: { + messageID: string + files?: boolean + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/revert/stage" +} + +export type V2SessionRevertStageErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * MessageNotFoundError | SessionNotFoundError + */ + 404: MessageNotFoundError | SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionRevertStageError = V2SessionRevertStageErrors[keyof V2SessionRevertStageErrors] + +export type V2SessionRevertStageResponses = { + /** + * Success + */ + 200: { + data: RevertState + } +} + +export type V2SessionRevertStageResponse = V2SessionRevertStageResponses[keyof V2SessionRevertStageResponses] + +export type V2SessionRevertClearData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/revert/clear" +} + +export type V2SessionRevertClearErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionRevertClearError = V2SessionRevertClearErrors[keyof V2SessionRevertClearErrors] + +export type V2SessionRevertClearResponses = { + /** + * + */ + 204: void +} + +export type V2SessionRevertClearResponse = V2SessionRevertClearResponses[keyof V2SessionRevertClearResponses] + +export type V2SessionRevertCommitData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/revert/commit" +} + +export type V2SessionRevertCommitErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionRevertCommitError = V2SessionRevertCommitErrors[keyof V2SessionRevertCommitErrors] + +export type V2SessionRevertCommitResponses = { + /** + * + */ + 204: void +} + +export type V2SessionRevertCommitResponse = V2SessionRevertCommitResponses[keyof V2SessionRevertCommitResponses] + export type V2SessionContextData = { body?: never path: { @@ -14825,6 +18963,158 @@ export type V2SessionContextResponses = { export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] +export type V2SessionHistoryData = { + body?: never + path: { + sessionID: string + } + query?: { + limit?: number + after?: number + } + url: "/api/session/{sessionID}/history" +} + +export type V2SessionHistoryErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionHistoryError = V2SessionHistoryErrors[keyof V2SessionHistoryErrors] + +export type V2SessionHistoryResponses = { + /** + * SessionHistory + */ + 200: SessionHistory +} + +export type V2SessionHistoryResponse = V2SessionHistoryResponses[keyof V2SessionHistoryResponses] + +export type V2SessionEventsData = { + body?: never + path: { + sessionID: string + } + query?: { + after?: string + } + url: "/api/session/{sessionID}/event" +} + +export type V2SessionEventsErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionEventsError = V2SessionEventsErrors[keyof V2SessionEventsErrors] + +export type V2SessionEventsResponses = { + /** + * Success + */ + 200: { + id: string + event: string + data: SessionDurableEventStream + } +} + +export type V2SessionEventsResponse = V2SessionEventsResponses[keyof V2SessionEventsResponses] + +export type V2SessionInterruptData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/interrupt" +} + +export type V2SessionInterruptErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionInterruptError = V2SessionInterruptErrors[keyof V2SessionInterruptErrors] + +export type V2SessionInterruptResponses = { + /** + * + */ + 204: void +} + +export type V2SessionInterruptResponse = V2SessionInterruptResponses[keyof V2SessionInterruptResponses] + +export type V2SessionMessageData = { + body?: never + path: { + sessionID: string + messageID: string + } + query?: never + url: "/api/session/{sessionID}/message/{messageID}" +} + +export type V2SessionMessageErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | MessageNotFoundError + */ + 404: MessageNotFoundError | SessionNotFoundError +} + +export type V2SessionMessageError = V2SessionMessageErrors[keyof V2SessionMessageErrors] + +export type V2SessionMessageResponses = { + /** + * Success + */ + 200: { + data: SessionMessage + } +} + +export type V2SessionMessageResponse = V2SessionMessageResponses[keyof V2SessionMessageResponses] + export type V2SessionMessagesData = { body?: never path: { @@ -15231,36 +19521,7 @@ export type V2IntegrationAttemptStatusResponses = { */ 200: { location: LocationInfo - data: - | { - status: "pending" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "complete" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "failed" - message: string - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } - | { - status: "expired" - time: { - created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - } + data: IntegrationAttemptStatus } } @@ -15519,6 +19780,95 @@ export type V2SessionPermissionListResponses = { export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses] +export type V2SessionPermissionCreateData = { + body: { + id?: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + agent?: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/permission" +} + +export type V2SessionPermissionCreateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionPermissionCreateError = V2SessionPermissionCreateErrors[keyof V2SessionPermissionCreateErrors] + +export type V2SessionPermissionCreateResponses = { + /** + * Success + */ + 200: { + data: { + id: string + effect: PermissionV2Effect + } + } +} + +export type V2SessionPermissionCreateResponse = + V2SessionPermissionCreateResponses[keyof V2SessionPermissionCreateResponses] + +export type V2SessionPermissionGetData = { + body?: never + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/permission/{requestID}" +} + +export type V2SessionPermissionGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | PermissionNotFoundError + */ + 404: PermissionNotFoundError | SessionNotFoundError +} + +export type V2SessionPermissionGetError = V2SessionPermissionGetErrors[keyof V2SessionPermissionGetErrors] + +export type V2SessionPermissionGetResponses = { + /** + * Success + */ + 200: { + data: PermissionV2Request + } +} + +export type V2SessionPermissionGetResponse = V2SessionPermissionGetResponses[keyof V2SessionPermissionGetResponses] + export type V2SessionPermissionReplyData = { body: { reply: PermissionV2Reply @@ -15749,12 +20099,7 @@ export type V2SkillListResponse = V2SkillListResponses[keyof V2SkillListResponse export type V2EventSubscribeData = { body?: never path?: never - query?: { - location?: { - directory?: string - workspace?: string - } - } + query?: never url: "/api/event" } @@ -15773,9 +20118,9 @@ export type V2EventSubscribeError = V2EventSubscribeErrors[keyof V2EventSubscrib export type V2EventSubscribeResponses = { /** - * Success + * Event stream */ - 200: string + 200: V2Event } export type V2EventSubscribeResponse = V2EventSubscribeResponses[keyof V2EventSubscribeResponses] @@ -15948,7 +20293,6 @@ export type V2PtyGetResponse = V2PtyGetResponses[keyof V2PtyGetResponses] export type V2PtyUpdateData = { body: { title?: string - sessionID?: string size?: { rows: number cols: number @@ -16036,10 +20380,7 @@ export type V2PtyConnectTokenResponses = { */ 200: { location: LocationInfo - data: { - ticket: string - expires_in: number - } + data: PtyTicketConnectToken } } diff --git a/packages/sdk/js/test/session-history.test.ts b/packages/sdk/js/test/session-history.test.ts new file mode 100644 index 00000000000..44a97433316 --- /dev/null +++ b/packages/sdk/js/test/session-history.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import type { V2SessionHistoryData } from "../src/v2/gen/types.gen" + +test("uses numeric Session history positions", () => { + const input = { + path: { sessionID: "ses_test" }, + query: { after: 1, limit: 50 }, + url: "/api/session/{sessionID}/history", + } satisfies V2SessionHistoryData + + expect(input.query.after).toBe(1) +}) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index bba91e9b921..e47e125728e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -4168,29 +4168,10 @@ "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" } }, "additionalProperties": false @@ -4725,17 +4706,6 @@ "title": { "type": "string" }, - "sessionID": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses" - }, - { - "type": "null" - } - ] - }, "size": { "type": "object", "properties": { @@ -4750,6 +4720,17 @@ }, "required": ["rows", "cols"], "additionalProperties": false + }, + "sessionID": { + "anyOf": [ + { + "type": "string", + "pattern": "^ses" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false @@ -4874,19 +4855,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "ticket": { - "type": "string" - }, - "expires_in": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["ticket", "expires_in"], - "additionalProperties": false, - "description": "WebSocket connect token" + "$ref": "#/components/schemas/PtyTicketConnectToken" } } } @@ -6904,6 +6873,12 @@ "editorContext": { "type": "object", "properties": { + "directory": { + "type": "string" + }, + "worktree": { + "type": "string" + }, "visibleFiles": { "type": "array", "items": { @@ -7814,6 +7789,12 @@ "editorContext": { "type": "object", "properties": { + "directory": { + "type": "string" + }, + "worktree": { + "type": "string" + }, "visibleFiles": { "type": "array", "items": { @@ -8750,7 +8731,7 @@ "properties": { "id": { "type": "string", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$", "format": "uuid" }, "active": { @@ -10320,19 +10301,7 @@ "schema": { "type": "array", "items": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"], - "additionalProperties": false + "$ref": "#/components/schemas/WorkspaceEventConnectionStatus" }, "description": "Workspace status" } @@ -20387,20 +20356,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "location": { "$ref": "#/components/schemas/LocationRef" @@ -20420,6 +20376,66 @@ ] } }, + "/api/session/active": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.active", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "patternProperties": { + "^ses": { + "$ref": "#/components/schemas/SessionActive" + } + } + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve foreground Session drains currently owned by this Kilo process. Sessions absent from the result are inactive.", + "summary": "List active sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.active({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}": { "get": { "tags": ["sessions"], @@ -20502,6 +20518,176 @@ ] } }, + "/api/session/{sessionID}/agent": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchAgent", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the agent used by subsequent provider turns.", + "summary": "Switch session agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "string" + } + }, + "required": ["agent"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.switchAgent({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/model": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchModel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the model used by subsequent provider turns.", + "summary": "Switch session model", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/ModelRef" + } + }, + "required": ["model"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.switchModel({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": ["sessions"], @@ -20597,7 +20783,7 @@ "pattern": "^msg_" }, "prompt": { - "$ref": "#/components/schemas/Prompt" + "$ref": "#/components/schemas/PromptInput" }, "delivery": { "type": "string", @@ -20778,6 +20964,266 @@ ] } }, + "/api/session/{sessionID}/revert/stage": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.revert.stage", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "MessageNotFoundError | SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError1" + } + } + } + } + }, + "description": "Stage or move a reversible session boundary and optionally apply its file changes.", + "summary": "Stage session revert", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "files": { + "type": "boolean" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.revert.stage({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/revert/clear": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.revert.clear", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + }, + "500": { + "description": "UnknownError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnknownError1" + } + } + } + } + }, + "summary": "Clear staged revert", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.revert.clear({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/revert/commit": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.revert.commit", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "summary": "Commit staged revert", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.revert.commit({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/context": { "get": { "tags": ["sessions"], @@ -20873,6 +21319,416 @@ ] } }, + "/api/session/{sessionID}/history": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.history", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionHistory", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionHistory" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.", + "summary": "Get session history", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.history({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/event": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.events", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "text/event-stream": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "event": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/SessionDurableEventStream" + } + }, + "required": ["id", "event", "data"], + "additionalProperties": false + }, + "x-effect-stream": { + "encoding": "sse", + "causeSchema": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Fail"] + }, + "error": { + "not": {} + } + }, + "required": ["_tag", "error"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Die"] + }, + "defect": {} + }, + "required": ["_tag", "defect"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Interrupt"] + }, + "fiberId": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["_tag", "fiberId"], + "additionalProperties": false + } + ] + } + }, + "errorSchema": { + "not": {} + }, + "failureEvent": "effect/httpapi/stream/failure" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Replay durable events after an aggregate sequence, then continue with new durable events.", + "summary": "Subscribe to session events", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.events({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/interrupt": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.interrupt", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Interrupt active execution owned by this Kilo process. Idle interruption is a no-op.", + "summary": "Interrupt session execution", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.interrupt({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/message/{messageID}": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.message", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionMessage" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | MessageNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve one projected message owned by the Session.", + "summary": "Get session message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.message({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/message": { "get": { "tags": ["messages"], @@ -21707,267 +22563,7 @@ "$ref": "#/components/schemas/LocationInfo" }, "data": { - "anyOf": [ - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["pending"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "time"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["complete"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "time"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["failed"] - }, - "message": { - "type": "string" - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "message", "time"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["expired"] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "expires": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["created", "expires"], - "additionalProperties": false - } - }, - "required": ["status", "time"], - "additionalProperties": false - } - ] + "$ref": "#/components/schemas/IntegrationAttemptStatus" } }, "required": ["location", "data"], @@ -22518,6 +23114,139 @@ } }, "/api/session/{sessionID}/permission": { + "post": { + "tags": ["permissions"], + "operationId": "v2.session.permission.create", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "effect": { + "$ref": "#/components/schemas/PermissionV2Effect" + } + }, + "required": ["id", "effect"], + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Evaluate and, when approval is required, create a permission request for a session.", + "summary": "Create permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2Source" + }, + "agent": { + "type": "string" + } + }, + "required": ["action", "resources"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.permission.create({\n ...\n})" + } + ] + }, "get": { "tags": ["permissions"], "operationId": "v2.session.permission.list", @@ -22602,6 +23331,100 @@ ] } }, + "/api/session/{sessionID}/permission/{requestID}": { + "get": { + "tags": ["permissions"], + "operationId": "v2.session.permission.get", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^per" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/PermissionV2Request" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError | PermissionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Retrieve a pending permission request owned by a session.", + "summary": "Get permission request", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.session.permission.get({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/permission/{requestID}/reply": { "post": { "tags": ["permissions"], @@ -23140,35 +23963,15 @@ "get": { "tags": ["events"], "operationId": "v2.event.subscribe", - "parameters": [ - { - "name": "location", - "in": "query", - "schema": { - "type": "object", - "properties": { - "directory": { - "type": "string" - }, - "workspace": { - "type": "string" - } - }, - "additionalProperties": false - }, - "required": false, - "style": "deepObject", - "explode": true - } - ], + "parameters": [], "security": [], "responses": { "200": { - "description": "Success", + "description": "Event stream", "content": { "text/event-stream": { "schema": { - "type": "string" + "$ref": "#/components/schemas/V2Event" } } } @@ -23194,7 +23997,7 @@ } } }, - "description": "Subscribe to native event payloads for a location.", + "description": "Subscribe to native event payloads for the server.", "summary": "Subscribe to events", "x-codeSamples": [ { @@ -23588,10 +24391,6 @@ "title": { "type": "string" }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, "size": { "type": "object", "properties": { @@ -23747,18 +24546,7 @@ "$ref": "#/components/schemas/LocationInfo" }, "data": { - "type": "object", - "properties": { - "ticket": { - "type": "string" - }, - "expires_in": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["ticket", "expires_in"], - "additionalProperties": false + "$ref": "#/components/schemas/PtyTicketConnectToken" } }, "required": ["location", "data"], @@ -24674,6 +25462,273 @@ "schemas": { "Event": { "anyOf": [ + { + "$ref": "#/components/schemas/EventModels-devRefreshed1" + }, + { + "$ref": "#/components/schemas/EventIntegrationUpdated1" + }, + { + "$ref": "#/components/schemas/EventIntegrationConnectionUpdated1" + }, + { + "$ref": "#/components/schemas/EventCatalogUpdated1" + }, + { + "$ref": "#/components/schemas/EventSessionCreated1" + }, + { + "$ref": "#/components/schemas/EventSessionUpdated1" + }, + { + "$ref": "#/components/schemas/EventSessionDeleted1" + }, + { + "$ref": "#/components/schemas/EventMessageUpdated1" + }, + { + "$ref": "#/components/schemas/EventMessageRemoved1" + }, + { + "$ref": "#/components/schemas/EventMessagePartUpdated1" + }, + { + "$ref": "#/components/schemas/EventMessagePartRemoved1" + }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched1" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched1" + }, + { + "$ref": "#/components/schemas/EventSessionNextMoved1" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted1" + }, + { + "$ref": "#/components/schemas/EventSessionNextPromptAdmitted1" + }, + { + "$ref": "#/components/schemas/EventSessionNextContextUpdated1" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic1" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted1" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded1" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted1" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded1" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed1" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted1" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta1" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded1" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted1" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta1" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded1" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted1" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta1" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded1" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled1" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress1" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess1" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed1" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried1" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted1" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta1" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded1" + }, + { + "$ref": "#/components/schemas/EventSessionNextRevertStaged1" + }, + { + "$ref": "#/components/schemas/EventSessionNextRevertCleared1" + }, + { + "$ref": "#/components/schemas/EventSessionNextRevertCommitted1" + }, + { + "$ref": "#/components/schemas/EventMessagePartDelta1" + }, + { + "$ref": "#/components/schemas/EventSessionDiff1" + }, + { + "$ref": "#/components/schemas/EventSessionError1" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdated1" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdate-available1" + }, + { + "$ref": "#/components/schemas/EventFileEdited1" + }, + { + "$ref": "#/components/schemas/EventReferenceUpdated1" + }, + { + "$ref": "#/components/schemas/EventPermissionV2Asked1" + }, + { + "$ref": "#/components/schemas/EventPermissionV2Replied1" + }, + { + "$ref": "#/components/schemas/EventPluginAdded1" + }, + { + "$ref": "#/components/schemas/EventProjectDirectoriesUpdated1" + }, + { + "$ref": "#/components/schemas/EventFileWatcherUpdated1" + }, + { + "$ref": "#/components/schemas/EventPtyCreated1" + }, + { + "$ref": "#/components/schemas/EventPtyUpdated1" + }, + { + "$ref": "#/components/schemas/EventPtyExited1" + }, + { + "$ref": "#/components/schemas/EventPtyDeleted1" + }, + { + "$ref": "#/components/schemas/EventQuestionV2Asked1" + }, + { + "$ref": "#/components/schemas/EventQuestionV2Replied1" + }, + { + "$ref": "#/components/schemas/EventQuestionV2Rejected1" + }, + { + "$ref": "#/components/schemas/EventTodoUpdated1" + }, + { + "$ref": "#/components/schemas/EventLspUpdated1" + }, + { + "$ref": "#/components/schemas/EventPermissionAsked1" + }, + { + "$ref": "#/components/schemas/EventPermissionReplied1" + }, + { + "$ref": "#/components/schemas/EventTuiPromptAppend1" + }, + { + "$ref": "#/components/schemas/EventTuiCommandExecute1" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/EventTuiSessionSelect1" + }, + { + "$ref": "#/components/schemas/EventMcpToolsChanged1" + }, + { + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed1" + }, + { + "$ref": "#/components/schemas/EventCommandExecuted1" + }, + { + "$ref": "#/components/schemas/EventProjectUpdated1" + }, + { + "$ref": "#/components/schemas/EventSessionStatus1" + }, + { + "$ref": "#/components/schemas/EventSessionIdle1" + }, + { + "$ref": "#/components/schemas/EventQuestionAsked1" + }, + { + "$ref": "#/components/schemas/EventQuestionReplied1" + }, + { + "$ref": "#/components/schemas/EventQuestionRejected1" + }, + { + "$ref": "#/components/schemas/EventSessionCompacted1" + }, + { + "$ref": "#/components/schemas/EventVcsBranchUpdated1" + }, + { + "$ref": "#/components/schemas/EventWorkspaceReady1" + }, + { + "$ref": "#/components/schemas/EventWorkspaceFailed1" + }, + { + "$ref": "#/components/schemas/EventWorkspaceStatus1" + }, + { + "$ref": "#/components/schemas/EventWorktreeReady1" + }, + { + "$ref": "#/components/schemas/EventWorktreeFailed1" + }, + { + "$ref": "#/components/schemas/EventServerConnected1" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed1" + }, + { + "$ref": "#/components/schemas/EventGlobalConfigUpdated1" + }, { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, @@ -24764,21 +25819,12 @@ { "$ref": "#/components/schemas/EventModels-devRefreshed" }, - { - "$ref": "#/components/schemas/EventServerConnected" - }, - { - "$ref": "#/components/schemas/EventGlobalDisposed" - }, - { - "$ref": "#/components/schemas/EventGlobalConfigUpdated" - }, - { - "$ref": "#/components/schemas/EventPluginAdded" - }, { "$ref": "#/components/schemas/EventIntegrationUpdated" }, + { + "$ref": "#/components/schemas/EventIntegrationConnectionUpdated" + }, { "$ref": "#/components/schemas/EventCatalogUpdated" }, @@ -24818,12 +25864,6 @@ { "$ref": "#/components/schemas/EventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/EventSessionNextPromptPromoted" - }, - { - "$ref": "#/components/schemas/EventSessionNextInterruptRequested" - }, { "$ref": "#/components/schemas/EventSessionNextContextUpdated" }, @@ -24897,31 +25937,13 @@ "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, { - "$ref": "#/components/schemas/EventQuestionAsked" + "$ref": "#/components/schemas/EventSessionNextRevertStaged" }, { - "$ref": "#/components/schemas/EventQuestionReplied" + "$ref": "#/components/schemas/EventSessionNextRevertCleared" }, { - "$ref": "#/components/schemas/EventQuestionRejected" - }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/EventTuiToastShow1" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventMcpToolsChanged" - }, - { - "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" + "$ref": "#/components/schemas/EventSessionNextRevertCommitted" }, { "$ref": "#/components/schemas/EventMessagePartDelta" @@ -24941,6 +25963,9 @@ { "$ref": "#/components/schemas/EventFileEdited" }, + { + "$ref": "#/components/schemas/EventReferenceUpdated" + }, { "$ref": "#/components/schemas/EventPermissionV2Asked" }, @@ -24948,7 +25973,7 @@ "$ref": "#/components/schemas/EventPermissionV2Replied" }, { - "$ref": "#/components/schemas/EventReferenceUpdated" + "$ref": "#/components/schemas/EventPluginAdded" }, { "$ref": "#/components/schemas/EventProjectDirectoriesUpdated" @@ -24990,13 +26015,22 @@ "$ref": "#/components/schemas/EventPermissionReplied" }, { - "$ref": "#/components/schemas/EventSessionStatus" + "$ref": "#/components/schemas/Event.tui.prompt.append" }, { - "$ref": "#/components/schemas/EventSessionIdle" + "$ref": "#/components/schemas/Event.tui.command.execute" }, { - "$ref": "#/components/schemas/EventSessionCompacted" + "$ref": "#/components/schemas/EventTuiToastShow2" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventMcpToolsChanged" + }, + { + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" }, { "$ref": "#/components/schemas/EventCommandExecuted" @@ -25004,6 +26038,24 @@ { "$ref": "#/components/schemas/EventProjectUpdated" }, + { + "$ref": "#/components/schemas/EventSessionStatus" + }, + { + "$ref": "#/components/schemas/EventSessionIdle" + }, + { + "$ref": "#/components/schemas/EventQuestionAsked" + }, + { + "$ref": "#/components/schemas/EventQuestionReplied" + }, + { + "$ref": "#/components/schemas/EventQuestionRejected" + }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -25022,6 +26074,15 @@ { "$ref": "#/components/schemas/EventWorktreeFailed" }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + }, + { + "$ref": "#/components/schemas/EventGlobalConfigUpdated" + }, { "$ref": "#/components/schemas/EventServerInstanceDisposed" } @@ -26029,10 +27090,6 @@ }, "diff": { "type": "string" - }, - "workspace": { - "type": "string", - "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -26163,6 +27220,12 @@ "editorContext": { "type": "object", "properties": { + "directory": { + "type": "string" + }, + "worktree": { + "type": "string" + }, "visibleFiles": { "type": "array", "items": { @@ -27057,17 +28120,6 @@ }, "snapshot": { "type": "string" - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["start"], - "additionalProperties": false } }, "required": ["id", "sessionID", "messageID", "type"], @@ -27429,84 +28481,74 @@ "required": ["text"], "additionalProperties": false }, - "QuestionOption": { + "Pty": { "type": "object", "properties": { - "label": { + "id": { "type": "string", - "description": "Display text (1-5 words, concise)" + "pattern": "^pty" }, - "description": { - "type": "string", - "description": "Explanation of choice" - }, - "labelKey": { + "title": { "type": "string" }, - "descriptionKey": { + "command": { "type": "string" }, - "mode": { - "type": "string" - } - }, - "required": ["label", "description"], - "additionalProperties": false - }, - "QuestionInfo": { - "type": "object", - "properties": { - "question": { - "type": "string", - "description": "Complete question" - }, - "header": { - "type": "string", - "description": "Very short label (max 30 chars)" - }, - "options": { + "args": { "type": "array", "items": { - "$ref": "#/components/schemas/QuestionOption" - }, - "description": "Available choices" + "type": "string" + } }, - "multiple": { - "type": "boolean" - }, - "questionKey": { + "cwd": { "type": "string" }, - "headerKey": { - "type": "string" + "status": { + "type": "string", + "enum": ["running", "exited"] }, - "custom": { - "type": "boolean" + "pid": { + "type": "integer", + "minimum": 0 + }, + "exitCode": { + "type": "integer", + "minimum": 0 + }, + "sessionID": { + "anyOf": [ + { + "type": "string", + "pattern": "^ses" + }, + { + "type": "null" + } + ] } }, - "required": ["question", "header", "options"], + "required": ["id", "title", "command", "args", "cwd", "status", "pid"], "additionalProperties": false }, - "QuestionTool": { + "Todo": { "type": "object", "properties": { - "messageID": { + "content": { "type": "string", - "pattern": "^msg" + "description": "Brief description of the task" }, - "callID": { - "type": "string" + "status": { + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" + }, + "priority": { + "type": "string", + "description": "Priority level of the task: high, medium, low" } }, - "required": ["messageID", "callID"], + "required": ["content", "status", "priority"], "additionalProperties": false }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } - }, "Event.tui.prompt.append": { "type": "object", "properties": { @@ -27641,167 +28683,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "AgentRequirementError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": ["AgentRequirementError"] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "directory": { - "type": "string" - }, - "state": { - "type": "string", - "enum": ["blocked", "error"] - }, - "skills": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["ready", "missing", "error"] - }, - "message": { - "type": "string" - } - }, - "required": ["name", "status"], - "additionalProperties": false - } - }, - "mcps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["ready", "missing", "error"] - }, - "message": { - "type": "string" - } - }, - "required": ["name", "status"], - "additionalProperties": false - } - }, - "vscode_extensions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "\\S" - }, - "id": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" - } - }, - "required": ["name", "id"], - "additionalProperties": false - } - } - }, - "required": ["message", "agent", "directory", "state", "skills", "mcps", "vscode_extensions"], - "additionalProperties": false - } - }, - "required": ["name", "data"], - "additionalProperties": false - }, - "Pty": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty" - }, - "title": { - "type": "string" - }, - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["running", "exited"] - }, - "pid": { - "type": "integer", - "minimum": 0 - }, - "exitCode": { - "type": "integer", - "minimum": 0 - }, - "sessionID": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses" - }, - { - "type": "null" - } - ] - } - }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"], - "additionalProperties": false - }, - "Todo": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Brief description of the task" - }, - "status": { - "type": "string", - "description": "Current status of the task: pending, in_progress, completed, cancelled" - }, - "priority": { - "type": "string", - "description": "Priority level of the task: high, medium, low" - } - }, - "required": ["content", "status", "priority"], - "additionalProperties": false - }, "SessionStatus": { "anyOf": [ { @@ -27893,6 +28774,84 @@ } ] }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display text (1-5 words, concise)" + }, + "description": { + "type": "string", + "description": "Explanation of choice" + }, + "labelKey": { + "type": "string" + }, + "descriptionKey": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": ["label", "description"], + "additionalProperties": false + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Complete question" + }, + "header": { + "type": "string", + "description": "Very short label (max 30 chars)" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + }, + "description": "Available choices" + }, + "multiple": { + "type": "boolean" + }, + "questionKey": { + "type": "string" + }, + "headerKey": { + "type": "string" + }, + "custom": { + "type": "boolean" + } + }, + "required": ["question", "header", "options"], + "additionalProperties": false + }, + "QuestionTool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, "GlobalEvent": { "type": "object", "properties": { @@ -27997,21 +28956,12 @@ { "$ref": "#/components/schemas/EventModels-devRefreshed" }, - { - "$ref": "#/components/schemas/EventServerConnected" - }, - { - "$ref": "#/components/schemas/EventGlobalDisposed" - }, - { - "$ref": "#/components/schemas/EventGlobalConfigUpdated" - }, - { - "$ref": "#/components/schemas/EventPluginAdded" - }, { "$ref": "#/components/schemas/EventIntegrationUpdated" }, + { + "$ref": "#/components/schemas/EventIntegrationConnectionUpdated" + }, { "$ref": "#/components/schemas/EventCatalogUpdated" }, @@ -28051,12 +29001,6 @@ { "$ref": "#/components/schemas/EventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/EventSessionNextPromptPromoted" - }, - { - "$ref": "#/components/schemas/EventSessionNextInterruptRequested" - }, { "$ref": "#/components/schemas/EventSessionNextContextUpdated" }, @@ -28130,31 +29074,13 @@ "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, { - "$ref": "#/components/schemas/EventQuestionAsked" + "$ref": "#/components/schemas/EventSessionNextRevertStaged" }, { - "$ref": "#/components/schemas/EventQuestionReplied" + "$ref": "#/components/schemas/EventSessionNextRevertCleared" }, { - "$ref": "#/components/schemas/EventQuestionRejected" - }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventMcpToolsChanged" - }, - { - "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" + "$ref": "#/components/schemas/EventSessionNextRevertCommitted" }, { "$ref": "#/components/schemas/EventMessagePartDelta" @@ -28174,6 +29100,9 @@ { "$ref": "#/components/schemas/EventFileEdited" }, + { + "$ref": "#/components/schemas/EventReferenceUpdated" + }, { "$ref": "#/components/schemas/EventPermissionV2Asked" }, @@ -28181,7 +29110,7 @@ "$ref": "#/components/schemas/EventPermissionV2Replied" }, { - "$ref": "#/components/schemas/EventReferenceUpdated" + "$ref": "#/components/schemas/EventPluginAdded" }, { "$ref": "#/components/schemas/EventProjectDirectoriesUpdated" @@ -28223,13 +29152,22 @@ "$ref": "#/components/schemas/EventPermissionReplied" }, { - "$ref": "#/components/schemas/EventSessionStatus" + "$ref": "#/components/schemas/Event.tui.prompt.append" }, { - "$ref": "#/components/schemas/EventSessionIdle" + "$ref": "#/components/schemas/Event.tui.command.execute" }, { - "$ref": "#/components/schemas/EventSessionCompacted" + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventMcpToolsChanged" + }, + { + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" }, { "$ref": "#/components/schemas/EventCommandExecuted" @@ -28237,6 +29175,24 @@ { "$ref": "#/components/schemas/EventProjectUpdated" }, + { + "$ref": "#/components/schemas/EventSessionStatus" + }, + { + "$ref": "#/components/schemas/EventSessionIdle" + }, + { + "$ref": "#/components/schemas/EventQuestionAsked" + }, + { + "$ref": "#/components/schemas/EventQuestionReplied" + }, + { + "$ref": "#/components/schemas/EventQuestionRejected" + }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, { "$ref": "#/components/schemas/EventVcsBranchUpdated" }, @@ -28255,6 +29211,3042 @@ { "$ref": "#/components/schemas/EventWorktreeFailed" }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + }, + { + "$ref": "#/components/schemas/EventGlobalConfigUpdated" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["integration.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["integration.connection.updated"] + }, + "properties": { + "type": "object", + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": ["integrationID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["catalog.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + } + }, + "required": ["timestamp", "sessionID", "messageID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.moved"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "subdirectory": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "location"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.context.updated"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "callID", + "structured", + "content", + "provider" + ], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "text": { + "type": "string" + }, + "recent": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.staged"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.cleared"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.committed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": ["timestamp", "sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["permission.v2.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2Source" + } + }, + "required": ["id", "sessionID", "action", "resources"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["permission.v2.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "$ref": "#/components/schemas/PermissionV2Reply" + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["project.directories.updated"] + }, + "properties": { + "type": "object", + "properties": { + "projectID": { + "type": "string" + } + }, + "required": ["projectID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.v2.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2Tool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.v2.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Answer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.v2.rejected"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/ProjectVcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/ProjectIcon" + }, + "commands": { + "$ref": "#/components/schemas/ProjectCommands" + }, + "time": { + "$ref": "#/components/schemas/ProjectTime" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "blocking": { + "type": "boolean" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["global.config.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, @@ -28294,9 +32286,6 @@ { "$ref": "#/components/schemas/SyncEventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" - }, { "$ref": "#/components/schemas/SyncEventSessionNextContextUpdated" }, @@ -28356,6 +32345,15 @@ }, { "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRevertCommitted" } ] } @@ -28646,6 +32644,9 @@ "external_directory": { "$ref": "#/components/schemas/PermissionRuleConfig" }, + "markdown_source": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, "todowrite": { "$ref": "#/components/schemas/PermissionActionConfig" }, @@ -29414,6 +33415,9 @@ "hide_prompt_training_models": { "type": "boolean" }, + "privacy_mode": { + "type": "boolean" + }, "sandbox": { "type": "object", "properties": { @@ -31204,55 +35208,19 @@ "type": "string" }, "vcs": { - "type": "string", - "enum": ["git"] + "$ref": "#/components/schemas/ProjectVcs" }, "name": { "type": "string" }, "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectIcon" }, "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false + "$ref": "#/components/schemas/ProjectCommands" }, "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false + "$ref": "#/components/schemas/ProjectTime" }, "sandboxes": { "type": "array", @@ -35341,6 +39309,17 @@ "required": ["_tag", "message"], "additionalProperties": false }, + "SessionActive": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["running"] + } + }, + "required": ["type"], + "additionalProperties": false + }, "SessionNotFoundError": { "type": "object", "properties": { @@ -35358,6 +39337,28 @@ "required": ["_tag", "sessionID", "message"], "additionalProperties": false }, + "PromptInput": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptInputFileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } + } + }, + "required": ["text"], + "additionalProperties": false + }, "ServiceUnavailableError": { "type": "object", "properties": { @@ -35375,6 +39376,26 @@ "required": ["_tag", "message"], "additionalProperties": false }, + "MessageNotFoundError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["MessageNotFoundError"] + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "sessionID", "messageID", "message"], + "additionalProperties": false + }, "UnknownError1": { "type": "object", "properties": { @@ -35392,6 +39413,120 @@ "required": ["_tag", "message"], "additionalProperties": false }, + "SessionDurableEvent": { + "oneOf": [ + { + "$ref": "#/components/schemas/SessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextMoved" + }, + { + "$ref": "#/components/schemas/SessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/SessionNextContextUpdated" + }, + { + "$ref": "#/components/schemas/SessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRetried" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCommitted" + }, + { + "$ref": "#/components/schemas/SessionNextPromptPromoted" + } + ] + }, + "SessionHistory": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionDurableEvent" + } + }, + "hasMore": { + "type": "boolean" + } + }, + "required": ["data", "hasMore"], + "additionalProperties": false + }, + "SessionDurableEventStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/SessionDurableEvent" + }, + "contentMediaType": "application/json" + }, "SessionMessagesResponse": { "type": "object", "properties": { @@ -35434,6 +39569,478 @@ "required": ["_tag", "providerID", "message"], "additionalProperties": false }, + "OutputFormat1": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["json_schema"] + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "schema"], + "additionalProperties": false + } + ] + }, + "session.status": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "question.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "question.rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/Models-devRefreshed" + }, + { + "$ref": "#/components/schemas/IntegrationUpdated" + }, + { + "$ref": "#/components/schemas/IntegrationConnectionUpdated" + }, + { + "$ref": "#/components/schemas/CatalogUpdated" + }, + { + "$ref": "#/components/schemas/SessionCreated" + }, + { + "$ref": "#/components/schemas/SessionUpdated" + }, + { + "$ref": "#/components/schemas/SessionDeleted" + }, + { + "$ref": "#/components/schemas/MessageUpdated" + }, + { + "$ref": "#/components/schemas/MessageRemoved" + }, + { + "$ref": "#/components/schemas/MessagePartUpdated" + }, + { + "$ref": "#/components/schemas/MessagePartRemoved" + }, + { + "$ref": "#/components/schemas/SessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextMoved" + }, + { + "$ref": "#/components/schemas/SessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/SessionNextContextUpdated" + }, + { + "$ref": "#/components/schemas/SessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/SessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SessionNextRetried" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCommitted" + }, + { + "$ref": "#/components/schemas/MessagePartDelta" + }, + { + "$ref": "#/components/schemas/SessionDiff" + }, + { + "$ref": "#/components/schemas/SessionError" + }, + { + "$ref": "#/components/schemas/InstallationUpdated" + }, + { + "$ref": "#/components/schemas/InstallationUpdate-available" + }, + { + "$ref": "#/components/schemas/FileEdited" + }, + { + "$ref": "#/components/schemas/ReferenceUpdated" + }, + { + "$ref": "#/components/schemas/PermissionV2Asked" + }, + { + "$ref": "#/components/schemas/PermissionV2Replied" + }, + { + "$ref": "#/components/schemas/PluginAdded" + }, + { + "$ref": "#/components/schemas/ProjectDirectoriesUpdated" + }, + { + "$ref": "#/components/schemas/FileWatcherUpdated" + }, + { + "$ref": "#/components/schemas/PtyCreated" + }, + { + "$ref": "#/components/schemas/PtyUpdated" + }, + { + "$ref": "#/components/schemas/PtyExited" + }, + { + "$ref": "#/components/schemas/PtyDeleted" + }, + { + "$ref": "#/components/schemas/QuestionV2Asked" + }, + { + "$ref": "#/components/schemas/QuestionV2Replied" + }, + { + "$ref": "#/components/schemas/QuestionV2Rejected" + }, + { + "$ref": "#/components/schemas/TodoUpdated" + }, + { + "$ref": "#/components/schemas/LspUpdated" + }, + { + "$ref": "#/components/schemas/PermissionAsked" + }, + { + "$ref": "#/components/schemas/PermissionReplied" + }, + { + "$ref": "#/components/schemas/TuiPromptAppend" + }, + { + "$ref": "#/components/schemas/TuiCommandExecute" + }, + { + "$ref": "#/components/schemas/TuiToastShow" + }, + { + "$ref": "#/components/schemas/TuiSessionSelect" + }, + { + "$ref": "#/components/schemas/McpToolsChanged" + }, + { + "$ref": "#/components/schemas/McpBrowserOpenFailed" + }, + { + "$ref": "#/components/schemas/CommandExecuted" + }, + { + "$ref": "#/components/schemas/ProjectUpdated" + }, + { + "$ref": "#/components/schemas/session.status" + }, + { + "$ref": "#/components/schemas/SessionIdle" + }, + { + "$ref": "#/components/schemas/QuestionAsked" + }, + { + "$ref": "#/components/schemas/question.replied" + }, + { + "$ref": "#/components/schemas/question.rejected" + }, + { + "$ref": "#/components/schemas/SessionCompacted" + }, + { + "$ref": "#/components/schemas/VcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/WorkspaceReady" + }, + { + "$ref": "#/components/schemas/WorkspaceFailed" + }, + { + "$ref": "#/components/schemas/WorkspaceStatus" + }, + { + "$ref": "#/components/schemas/WorktreeReady" + }, + { + "$ref": "#/components/schemas/WorktreeFailed" + }, + { + "$ref": "#/components/schemas/ServerConnected" + }, + { + "$ref": "#/components/schemas/GlobalDisposed" + }, + { + "$ref": "#/components/schemas/GlobalConfigUpdated" + } + ] + }, + "V2EventStream": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/V2Event" + }, + "contentMediaType": "application/json" + }, "ForbiddenError": { "type": "object", "properties": { @@ -35567,6 +40174,61 @@ "required": ["id", "sessionID", "pid", "command", "cwd", "status", "cols", "rows", "time"], "additionalProperties": false }, + "CredentialValue": { + "anyOf": [ + { + "$ref": "#/components/schemas/CredentialOAuth" + }, + { + "$ref": "#/components/schemas/CredentialKey" + } + ] + }, + "IntegrationInputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "IntegrationMethod": { + "anyOf": [ + { + "$ref": "#/components/schemas/IntegrationOAuthMethod" + }, + { + "$ref": "#/components/schemas/IntegrationKeyMethod" + }, + { + "$ref": "#/components/schemas/IntegrationEnvMethod" + } + ] + }, + "IntegrationRef": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "additionalProperties": false + }, + "SkillV2Source": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillV2DirectorySource" + }, + { + "$ref": "#/components/schemas/SkillV2UrlSource" + }, + { + "$ref": "#/components/schemas/SkillV2EmbeddedSource" + } + ] + }, "MoveSessionDestination": { "type": "object", "properties": { @@ -37394,84 +42056,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventServerConnected": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["server.connected"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventGlobalDisposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["global.disposed"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventGlobalConfigUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["global.config.updated"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventPluginAdded": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["plugin.added"] - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventIntegrationUpdated": { "type": "object", "properties": { @@ -37490,6 +42074,30 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventIntegrationConnectionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["integration.connection.updated"] + }, + "properties": { + "type": "object", + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": ["integrationID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventCatalogUpdated": { "type": "object", "properties": { @@ -37748,6 +42356,22 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "ModelRef": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, "EventSessionNextModelSwitched": { "type": "object", "properties": { @@ -37773,20 +42397,7 @@ "pattern": "^msg_" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["timestamp", "sessionID", "messageID", "model"], @@ -37973,72 +42584,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionNextInterruptRequested": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.next.interrupt.requested"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSessionNextContextUpdated": { "type": "object", "properties": { @@ -38209,20 +42754,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "snapshot": { "type": "string" @@ -38296,6 +42828,12 @@ }, "snapshot": { "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } } }, "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], @@ -38465,6 +43003,12 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "LLMProviderMetadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, "EventSessionNextReasoningStarted": { "type": "object", "properties": { @@ -38493,10 +43037,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], @@ -38575,10 +43116,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], @@ -38742,10 +43280,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -38793,6 +43328,16 @@ "required": ["type", "uri", "mime"], "additionalProperties": false }, + "LLMToolContent": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + }, "EventSessionNextToolProgress": { "type": "object", "properties": { @@ -38826,14 +43371,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -38877,14 +43415,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -38901,10 +43432,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -38956,10 +43484,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -39147,14 +43672,65 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventQuestionAsked": { + "FileDiff": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["added", "modified", "deleted"] + }, + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "patch": { + "type": "string" + } + }, + "required": ["path", "status", "additions", "deletions", "patch"], + "additionalProperties": false + }, + "RevertState": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": ["messageID"], + "additionalProperties": false + }, + "EventSessionNextRevertStaged": { "type": "object", "properties": { "id": { @@ -39162,41 +43738,30 @@ }, "type": { "type": "string", - "enum": ["question.asked"] + "enum": ["session.next.revert.staged"] }, "properties": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^que" + "timestamp": { + "type": "number" }, "sessionID": { "type": "string", "pattern": "^ses" }, - "questions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - }, - "description": "Questions to ask" - }, - "blocking": { - "type": "boolean" - }, - "tool": { - "$ref": "#/components/schemas/QuestionTool" + "revert": { + "$ref": "#/components/schemas/RevertState" } }, - "required": ["id", "sessionID", "questions"], + "required": ["timestamp", "sessionID", "revert"], "additionalProperties": false } }, "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventQuestionReplied": { + "EventSessionNextRevertCleared": { "type": "object", "properties": { "id": { @@ -39204,107 +43769,52 @@ }, "type": { "type": "string", - "enum": ["question.replied"] + "enum": ["session.next.revert.cleared"] }, "properties": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextRevertCommitted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.committed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, "sessionID": { "type": "string", "pattern": "^ses" }, - "requestID": { + "messageID": { "type": "string", - "pattern": "^que" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } + "pattern": "^msg_" } }, - "required": ["sessionID", "requestID", "answers"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventQuestionRejected": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["question.rejected"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "requestID": { - "type": "string", - "pattern": "^que" - } - }, - "required": ["sessionID", "requestID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMcpToolsChanged": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["mcp.tools.changed"] - }, - "properties": { - "type": "object", - "properties": { - "server": { - "type": "string" - } - }, - "required": ["server"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventMcpBrowserOpenFailed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["mcp.browser.open.failed"] - }, - "properties": { - "type": "object", - "properties": { - "mcpName": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["mcpName", "url"], + "required": ["timestamp", "sessionID", "messageID"], "additionalProperties": false } }, @@ -39423,9 +43933,6 @@ }, { "$ref": "#/components/schemas/APIError" - }, - { - "$ref": "#/components/schemas/AgentRequirementError" } ] } @@ -39508,6 +44015,24 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventReferenceUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "PermissionV2Source": { "type": "object", "properties": { @@ -39611,7 +44136,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventReferenceUpdated": { + "EventPluginAdded": { "type": "object", "properties": { "id": { @@ -39619,11 +44144,17 @@ }, "type": { "type": "string", - "enum": ["reference.updated"] + "enum": ["plugin.added"] }, "properties": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } }, "required": ["id", "type", "properties"], @@ -40090,6 +44621,188 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventMcpToolsChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpBrowserOpenFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCommandExecuted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "ProjectVcs": { + "type": "string", + "enum": ["git"] + }, + "ProjectIcon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ProjectCommands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "ProjectTime": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "EventProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/ProjectVcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/ProjectIcon" + }, + "commands": { + "$ref": "#/components/schemas/ProjectCommands" + }, + "time": { + "$ref": "#/components/schemas/ProjectTime" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionStatus": { "type": "object", "properties": { @@ -40143,6 +44856,112 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventQuestionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "blocking": { + "type": "boolean" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionRejected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionCompacted": { "type": "object", "properties": { @@ -40168,125 +44987,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventCommandExecuted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["command.executed"] - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg" - } - }, - "required": ["name", "sessionID", "arguments", "messageID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventProjectUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["project.updated"] - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "worktree": { - "type": "string" - }, - "vcs": { - "type": "string", - "enum": ["git"] - }, - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - }, - "additionalProperties": false - }, - "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "Startup script to run when creating a new workspace (worktree)" - } - }, - "additionalProperties": false - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - }, - "initialized": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["created", "updated"], - "additionalProperties": false - }, - "sandboxes": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["id", "worktree", "time", "sandboxes"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventVcsBranchUpdated": { "type": "object", "properties": { @@ -40438,6 +45138,60 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalConfigUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.config.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "SyncEventSessionCreated": { "type": "object", "properties": { @@ -40897,20 +45651,7 @@ "pattern": "^msg_" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["timestamp", "sessionID", "messageID", "model"], @@ -41102,66 +45843,6 @@ "required": ["type", "id", "syncEvent"], "additionalProperties": false }, - "SyncEventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "syncEvent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted.1"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["type", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - } - }, - "required": ["type", "id", "syncEvent"], - "additionalProperties": false - }, "SyncEventSessionNextContextUpdated": { "type": "object", "properties": { @@ -41438,20 +46119,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "snapshot": { "type": "string" @@ -41547,6 +46215,12 @@ }, "snapshot": { "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } } }, "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], @@ -41780,10 +46454,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], @@ -41846,10 +46517,7 @@ "type": "string" }, "providerMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], @@ -42041,10 +46709,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -42113,14 +46778,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -42186,14 +46844,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -42210,10 +46861,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -42295,10 +46943,7 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -42486,7 +47131,164 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, + "SyncEventSessionNextRevertStaged": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.revert.staged.1"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, + "SyncEventSessionNextRevertCleared": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.revert.cleared.1"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, + "SyncEventSessionNextRevertCommitted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.revert.committed.1"] + }, + "id": { + "type": "string", + "pattern": "^evt_" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": ["timestamp", "sessionID", "messageID"], "additionalProperties": false } }, @@ -42569,6 +47371,35 @@ "additionalProperties": false } }, + "PtyTicketConnectToken": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false + }, + "WorkspaceEventConnectionStatus": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + }, "LocationInfo": { "type": "object", "properties": { @@ -42596,6 +47427,34 @@ "required": ["directory", "project"], "additionalProperties": false }, + "ProviderRequest": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": ["headers", "body"], + "additionalProperties": false + }, + "AgentColor": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + { + "type": "string", + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] + } + ] + }, "PermissionV2Effect": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -42629,36 +47488,10 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "request": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": ["headers", "body"], - "additionalProperties": false + "$ref": "#/components/schemas/ProviderRequest" }, "system": { "type": "string" @@ -42674,16 +47507,7 @@ "type": "boolean" }, "color": { - "anyOf": [ - { - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" - }, - { - "type": "string", - "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] - } - ] + "$ref": "#/components/schemas/AgentColor" }, "steps": { "type": "integer", @@ -42714,20 +47538,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "cost": { "type": "number" @@ -42785,11 +47596,33 @@ }, "subpath": { "type": "string" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" } }, "required": ["id", "projectID", "cost", "tokens", "time", "title", "location"], "additionalProperties": false }, + "PromptInputFileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["uri"], + "additionalProperties": false + }, "SessionInputAdmitted": { "type": "object", "properties": { @@ -42879,20 +47712,7 @@ "enum": ["model-switched"] }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" } }, "required": ["id", "time", "type", "model"], @@ -43078,10 +47898,20 @@ "type": "string" }, "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + }, + "time": { "type": "object", - "additionalProperties": { - "type": "object" - } + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false } }, "required": ["type", "id", "text"], @@ -43117,14 +47947,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } } }, @@ -43150,14 +47973,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "outputPaths": { @@ -43187,14 +48003,7 @@ "content": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolTextContent" - }, - { - "$ref": "#/components/schemas/ToolFileContent" - } - ] + "$ref": "#/components/schemas/LLMToolContent" } }, "structured": { @@ -43228,16 +48037,10 @@ "type": "boolean" }, "metadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" }, "resultMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "$ref": "#/components/schemas/LLMProviderMetadata" } }, "required": ["executed"], @@ -43313,20 +48116,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "content": { "type": "array", @@ -43352,6 +48142,12 @@ }, "end": { "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false @@ -43464,6 +48260,1944 @@ } ] }, + "SessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + } + }, + "required": ["timestamp", "sessionID", "messageID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextMoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.moved"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "subdirectory": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "location"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextPrompted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextPromptAdmitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextContextUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.context.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextShellStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextShellEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextStepStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolInputStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolInputEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolCalled": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolProgress": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRetried": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "text": { + "type": "string" + }, + "recent": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRevertStaged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.staged"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRevertCleared": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.cleared"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextRevertCommitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.committed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": ["timestamp", "sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextPromptPromoted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.promoted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "timeCreated": { + "type": "number" + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ModelApi": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "package"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["native"] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "settings"], + "additionalProperties": false + } + ] + }, + "ModelCapabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "ModelCost": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + }, "ModelV2Info": { "type": "object", "properties": { @@ -43480,73 +50214,10 @@ "type": "string" }, "api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "package"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["native"] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "settings"], - "additionalProperties": false - } - ] + "$ref": "#/components/schemas/ModelApi" }, "capabilities": { - "type": "object", - "properties": { - "tools": { - "type": "boolean" - }, - "input": { - "type": "array", - "items": { - "type": "string" - } - }, - "output": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["tools", "input", "output"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelCapabilities" }, "request": { "type": "object", @@ -43560,182 +50231,6 @@ "body": { "type": "object" }, - "generation": { - "type": "object", - "properties": { - "maxTokens": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "temperature": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topP": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topK": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "frequencyPenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "presencePenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "seed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "stop": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "options": { - "type": "object" - }, "variant": { "type": "string" } @@ -43759,182 +50254,6 @@ }, "body": { "type": "object" - }, - "generation": { - "type": "object", - "properties": { - "maxTokens": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "temperature": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topP": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topK": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "frequencyPenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "presencePenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "seed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "stop": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "options": { - "type": "object" } }, "required": ["id", "headers", "body"], @@ -43945,27 +50264,7 @@ "type": "object", "properties": { "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] + "type": "number" } }, "required": ["released"], @@ -43974,44 +50273,7 @@ "cost": { "type": "array", "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelCost" } }, "status": { @@ -44054,12 +50316,62 @@ ], "additionalProperties": false }, + "ProviderAISDK": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["type", "package"], + "additionalProperties": false + }, + "ProviderNative": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["native"] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["type", "settings"], + "additionalProperties": false + }, + "ProviderApi": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAISDK" + }, + { + "$ref": "#/components/schemas/ProviderNative" + } + ] + }, "ProviderV2Info": { "type": "object", "properties": { "id": { "type": "string" }, + "integrationID": { + "type": "string" + }, "name": { "type": "string" }, @@ -44067,61 +50379,10 @@ "type": "boolean" }, "api": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["type", "package"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["native"] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["type", "settings"], - "additionalProperties": false - } - ] + "$ref": "#/components/schemas/ProviderApi" }, "request": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": ["headers", "body"], - "additionalProperties": false + "$ref": "#/components/schemas/ProviderRequest" } }, "required": ["id", "name", "api", "request"], @@ -44320,17 +50581,7 @@ "methods": { "type": "array", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/IntegrationOAuthMethod" - }, - { - "$ref": "#/components/schemas/IntegrationKeyMethod" - }, - { - "$ref": "#/components/schemas/IntegrationEnvMethod" - } - ] + "$ref": "#/components/schemas/IntegrationMethod" } }, "connections": { @@ -44416,6 +50667,269 @@ "required": ["attemptID", "url", "instructions", "mode", "time"], "additionalProperties": false }, + "IntegrationAttemptStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["complete"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed"] + }, + "message": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "message", "time"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["expired"] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "expires": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["created", "expires"], + "additionalProperties": false + } + }, + "required": ["status", "time"], + "additionalProperties": false + } + ] + }, "PermissionV2Request": { "type": "object", "properties": { @@ -44480,12 +50994,9 @@ "type": { "type": "string", "enum": ["file", "directory"] - }, - "mime": { - "type": "string" } }, - "required": ["path", "type", "mime"], + "required": ["path", "type"], "additionalProperties": false }, "CommandV2Info": { @@ -44504,20 +51015,7 @@ "type": "string" }, "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false + "$ref": "#/components/schemas/ModelRef" }, "variant": { "type": "string" @@ -44551,6 +51049,3054 @@ "required": ["name", "location", "content"], "additionalProperties": false }, + "Models-devRefreshed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "IntegrationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["integration.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "IntegrationConnectionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["integration.connection.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": ["integrationID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "CatalogUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["catalog.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionCreated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessageRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessagePartUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessagePartRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextTextDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextReasoningDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextToolInputDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionNextCompactionDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessagePartDelta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionDiff": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionError": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "InstallationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "InstallationUpdate-available": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "FileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ReferenceUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PermissionV2Asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.v2.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2Source" + } + }, + "required": ["id", "sessionID", "action", "resources"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PermissionV2Replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.v2.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "$ref": "#/components/schemas/PermissionV2Reply" + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PluginAdded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ProjectDirectoriesUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["project.directories.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "projectID": { + "type": "string" + } + }, + "required": ["projectID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "FileWatcherUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyCreated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionV2Asked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.v2.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2Tool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionV2Replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.v2.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Answer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionV2Rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.v2.rejected"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TodoUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "LspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PermissionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PermissionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TuiPromptAppend": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TuiCommandExecute": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TuiToastShow": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "TuiSessionSelect": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "McpToolsChanged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "McpBrowserOpenFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "CommandExecuted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/ProjectVcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/ProjectIcon" + }, + "commands": { + "$ref": "#/components/schemas/ProjectCommands" + }, + "time": { + "$ref": "#/components/schemas/ProjectTime" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionIdle": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "QuestionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "blocking": { + "type": "boolean" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "VcsBranchUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorkspaceReady": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorkspaceFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorkspaceStatus": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorktreeReady": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "WorktreeFailed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "ServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "GlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "GlobalConfigUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["global.config.updated"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, "QuestionV2Request": { "type": "object", "properties": { @@ -44633,6 +54179,16 @@ "required": ["type", "repository"], "additionalProperties": false }, + "ReferenceSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReferenceLocalSource" + }, + { + "$ref": "#/components/schemas/ReferenceGitSource" + } + ] + }, "ReferenceInfo": { "type": "object", "properties": { @@ -44649,14 +54205,7 @@ "type": "boolean" }, "source": { - "anyOf": [ - { - "$ref": "#/components/schemas/ReferenceLocalSource" - }, - { - "$ref": "#/components/schemas/ReferenceGitSource" - } - ] + "$ref": "#/components/schemas/ReferenceSource" } }, "required": ["name", "path", "source"], @@ -44672,6 +54221,3025 @@ "required": ["directory"], "additionalProperties": false }, + "EventModels-devRefreshed1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["models-dev.refreshed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventIntegrationUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["integration.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventIntegrationConnectionUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["integration.connection.updated"] + }, + "properties": { + "type": "object", + "properties": { + "integrationID": { + "type": "string" + } + }, + "required": ["integrationID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCatalogUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["catalog.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCreated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDeleted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageRemoved1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "number" + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartRemoved1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextAgentSwitched1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextModelSwitched1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + } + }, + "required": ["timestamp", "sessionID", "messageID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextMoved1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.moved"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "subdirectory": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "location"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextPrompted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextPromptAdmitted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextContextUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.context.updated"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextSynthetic1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellStarted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellEnded1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepStarted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "agent": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/ModelRef" + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepEnded1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepFailed1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextStarted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextDelta1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextEnded1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "textID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningStarted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningDelta1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningEnded1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "providerMetadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputStarted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputDelta1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputEnded1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolCalled1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolProgress1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolSuccess1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMToolContent" + } + }, + "outputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolFailed1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "result": {}, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/LLMProviderMetadata" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextRetried1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionStarted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionDelta1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionEnded1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "text": { + "type": "string" + }, + "recent": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextRevertStaged1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.staged"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "revert": { + "$ref": "#/components/schemas/RevertState" + } + }, + "required": ["timestamp", "sessionID", "revert"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextRevertCleared1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.cleared"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextRevertCommitted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.next.revert.committed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + } + }, + "required": ["timestamp", "sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartDelta1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + }, + "partID": { + "type": "string", + "pattern": "^prt" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDiff1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionError1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/ContentFilterError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdate-available1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileEdited1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventReferenceUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionV2Asked1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["permission.v2.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/PermissionV2Source" + } + }, + "required": ["id", "sessionID", "action", "resources"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionV2Replied1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["permission.v2.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "$ref": "#/components/schemas/PermissionV2Reply" + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPluginAdded1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventProjectDirectoriesUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["project.directories.updated"] + }, + "properties": { + "type": "object", + "properties": { + "projectID": { + "type": "string" + } + }, + "required": ["projectID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileWatcherUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyCreated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyExited1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyDeleted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionV2Asked1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.v2.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Info" + }, + "description": "Questions to ask" + }, + "tool": { + "$ref": "#/components/schemas/QuestionV2Tool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionV2Replied1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.v2.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionV2Answer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionV2Rejected1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.v2.rejected"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventTodoUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionAsked1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object" + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"], + "additionalProperties": false + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionReplied1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^per" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventTuiPromptAppend1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventTuiCommandExecute1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventTuiToastShow1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventTuiSessionSelect1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpToolsChanged1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpBrowserOpenFailed1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCommandExecuted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventProjectUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/ProjectVcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/ProjectIcon" + }, + "commands": { + "$ref": "#/components/schemas/ProjectCommands" + }, + "time": { + "$ref": "#/components/schemas/ProjectTime" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionStatus1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionIdle1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionAsked1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + }, + "description": "Questions to ask" + }, + "blocking": { + "type": "boolean" + }, + "tool": { + "$ref": "#/components/schemas/QuestionTool" + } + }, + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionReplied1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionRejected1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCompacted1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventVcsBranchUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceReady1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceFailed1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceStatus1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeReady1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeFailed1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventServerConnected1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalConfigUpdated1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["global.config.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventMemoryStatus1": { "type": "object", "properties": { @@ -45569,7 +58137,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventTuiToastShow1": { + "EventTuiToastShow2": { "type": "object", "properties": { "id": { @@ -45604,6 +58172,92 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "CredentialOAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth"] + }, + "methodID": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "integer", + "minimum": 0 + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "methodID", "refresh", "access", "expires"], + "additionalProperties": false + }, + "CredentialKey": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["key"] + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "key"], + "additionalProperties": false + }, + "SkillV2DirectorySource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["directory"] + }, + "path": { + "type": "string" + } + }, + "required": ["type", "path"], + "additionalProperties": false + }, + "SkillV2UrlSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["url"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "SkillV2EmbeddedSource": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["embedded"] + }, + "skill": { + "$ref": "#/components/schemas/SkillV2Info" + } + }, + "required": ["type", "skill"], + "additionalProperties": false + }, "BadRequestError": { "type": "object", "required": ["name", "data"], diff --git a/packages/server/package.json b/packages/server/package.json index ed1bdf9aa14..99a80f091e6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -14,7 +14,8 @@ "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", - "effect": "catalog:" + "effect": "catalog:", + "@opencode-ai/protocol": "workspace:*" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 100a7246d31..981ad28db93 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,50 +1,8 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" -import { SchemaErrorMiddleware } from "./middleware/schema-error" -import { MessageGroup } from "./groups/message" -import { ModelGroup } from "./groups/model" -import { ProviderGroup } from "./groups/provider" -import { SessionGroup } from "./groups/session" -import { PermissionGroup } from "./groups/permission" -import { FileSystemGroup } from "./groups/fs" -import { CommandGroup } from "./groups/command" -import { SkillGroup } from "./groups/skill" -import { EventGroup } from "./groups/event" -import { AgentGroup } from "./groups/agent" -import { HealthGroup } from "./groups/health" -import { PtyGroup } from "./groups/pty" -import { QuestionGroup } from "./groups/question" -import { ReferenceGroup } from "./groups/reference" -import { Authorization } from "./middleware/authorization" -import { LocationGroup } from "./groups/location" -import { IntegrationGroup } from "./groups/integration" -import { CredentialGroup } from "./groups/credential" -import { ProjectCopyGroup } from "./groups/project-copy" +import { makeDefaultApi } from "@opencode-ai/protocol/api" +import { LocationMiddleware } from "./location" +import { SessionLocationMiddleware } from "./middleware/session-location" -export const Api = HttpApi.make("server") - .add(HealthGroup) - .add(LocationGroup) - .add(AgentGroup) - .add(SessionGroup) - .add(MessageGroup) - .add(ModelGroup) - .add(ProviderGroup) - .add(IntegrationGroup) - .add(CredentialGroup) - .add(PermissionGroup) - .add(FileSystemGroup) - .add(CommandGroup) - .add(SkillGroup) - .add(EventGroup) - .add(PtyGroup) - .add(QuestionGroup) - .add(ReferenceGroup) - .add(ProjectCopyGroup) - .annotateMerge( - OpenApi.annotations({ - title: "Kilo HttpApi", // kilocode_change - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - .middleware(Authorization) - .middleware(SchemaErrorMiddleware) +export const Api = makeDefaultApi({ + locationMiddleware: LocationMiddleware, + sessionLocationMiddleware: SessionLocationMiddleware, +}) diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts index 5822b651f61..0528711b7f5 100644 --- a/packages/server/src/auth.ts +++ b/packages/server/src/auth.ts @@ -18,11 +18,11 @@ export type Info = { } export class Config extends Context.Service()("@opencode/ServerAuthConfig") { - static layer(input: Info) { + static configLayer(input: Info) { return Layer.succeed(this, this.of(input)) } - static get defaultLayer() { + static get layer() { return Layer.effect( this, Effect.gen(function* () { diff --git a/packages/server/src/groups/agent.ts b/packages/server/src/groups/agent.ts deleted file mode 100644 index c9dd5398c57..00000000000 --- a/packages/server/src/groups/agent.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AgentV2 } from "@opencode-ai/core/agent" -import { Location } from "@opencode-ai/core/location" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const AgentGroup = HttpApiGroup.make("server.agent") - .add( - HttpApiEndpoint.get("agent.list", "/api/agent", { - query: LocationQuery, - success: Location.response(Schema.Array(AgentV2.Info)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.agent.list", - summary: "List agents", - description: "Retrieve currently registered agents.", - }), - ), - ) - .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/event.ts b/packages/server/src/groups/event.ts deleted file mode 100644 index 83ccdf98f73..00000000000 --- a/packages/server/src/groups/event.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -const Event = Schema.Struct({ - id: EventV2.ID, - type: Schema.String, - location: Location.Info.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - version: Schema.Number.pipe(Schema.optional), - data: Schema.Unknown, -}) - -export const EventGroup = HttpApiGroup.make("server.event") - .add( - HttpApiEndpoint.get("event.subscribe", "/api/event", { - query: LocationQuery, - success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.event.subscribe", - summary: "Subscribe to events", - description: "Subscribe to native event payloads for a location.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })) - .middleware(LocationMiddleware) - -export type Event = typeof Event.Type diff --git a/packages/server/src/groups/location.ts b/packages/server/src/groups/location.ts deleted file mode 100644 index 13884202157..00000000000 --- a/packages/server/src/groups/location.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { FileSystem } from "@opencode-ai/core/filesystem" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Effect, Layer, Schema } from "effect" -import { HttpServerRequest } from "effect/unstable/http" -import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" -import { InvalidRequestError } from "../errors" // kilocode_change - -export const LocationQuery = Schema.Struct({ - location: Schema.optional( - Schema.Struct({ - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - }), - ), -}).annotate({ identifier: "LocationQuery" }) - -export const locationQueryOpenApi = OpenApi.annotations({ - transform: (operation) => { - const parameters = operation.parameters - if (!Array.isArray(parameters)) return operation - return { - ...operation, - parameters: parameters.map((parameter) => - parameter?.name === "location" && parameter?.in === "query" - ? { ...parameter, style: "deepObject", explode: true } - : parameter, - ), - } - }, -}) - -export function response(data: Effect.Effect) { - return Effect.gen(function* () { - const location = yield* Location.Service - return { - location: new Location.Info({ - directory: location.directory, - workspaceID: location.workspaceID, - project: location.project, - }), - data: yield* data, - } - }) -} - -export type LocationServices = Layer.Success> - -export class LocationMiddleware extends HttpApiMiddleware.Service< - LocationMiddleware, - { - provides: LocationServices - } ->()("@opencode/HttpApiLocation", { error: InvalidRequestError }) {} // kilocode_change - surface malformed headers as 400s - -export const LocationGroup = HttpApiGroup.make("server.location") - .add( - HttpApiEndpoint.get("location.get", "/api/location", { - query: LocationQuery, - success: Location.Info, - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.location.get", - summary: "Get location", - description: "Resolve the requested location or the server default location.", - }), - ), - ) - .middleware(LocationMiddleware) - -function ref(request: HttpServerRequest.HttpServerRequest) { - const query = new URL(request.url, "http://localhost").searchParams - const workspaceID = query.get("location[workspace]") || request.headers["x-kilo-workspace"] - const header = request.headers["x-kilo-directory"] - // kilocode_change start - decode the SDK header without turning malformed client input into a defect - return Effect.try({ - try: () => query.get("location[directory]") || (header ? decodeURIComponent(header) : process.cwd()), - catch: () => new InvalidRequestError({ message: "Invalid encoded directory header", field: "x-kilo-directory" }), - }).pipe( - Effect.map((directory) => - Location.Ref.make({ - directory: AbsolutePath.make(directory), // kilocode_change - workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined, - }), - ), - ) - // kilocode_change end -} - -export const layer = Layer.effect( - LocationMiddleware, - Effect.gen(function* () { - const locations = yield* LocationServiceMap - return LocationMiddleware.of((effect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - const location = yield* ref(request) // kilocode_change - reject malformed encoded directory headers as 400s - return yield* effect.pipe(Effect.provide(locations.get(location))) - }), - ) - }), -) diff --git a/packages/server/src/groups/permission.ts b/packages/server/src/groups/permission.ts deleted file mode 100644 index a5b9e89a960..00000000000 --- a/packages/server/src/groups/permission.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { PermissionV2 } from "@opencode-ai/core/permission" -import { Location } from "@opencode-ai/core/location" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { ProjectV2 } from "@opencode-ai/core/project" -import { SessionV2 } from "@opencode-ai/core/session" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { PermissionNotFoundError, SessionNotFoundError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const PermissionGroup = HttpApiGroup.make("server.permission") - .add( - HttpApiEndpoint.get("permission.request.list", "/api/permission/request", { - query: LocationQuery, - success: Location.response(Schema.Array(PermissionV2.Request)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.permission.request.list", - summary: "List pending permission requests", - description: "Retrieve pending permission requests for a location.", - }), - ), - ) - .add( - HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", { - query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }), - success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }), - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.permission.saved.list", - summary: "List saved permissions", - description: "Retrieve saved permissions, optionally filtered by project.", - }), - ), - ) - .add( - HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", { - params: { id: PermissionSaved.ID }, - success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.permission.saved.remove", - summary: "Remove saved permission", - description: "Remove a saved permission by ID.", - }), - ), - ) - .middleware(LocationMiddleware) - .add( - HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: Schema.Array(PermissionV2.Request) }), - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.permission.list", - summary: "List session permission requests", - description: "Retrieve pending permission requests owned by a session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", { - params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID }, - payload: Schema.Struct({ - reply: PermissionV2.Reply, - message: Schema.String.pipe(Schema.optional), - }), - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, PermissionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.permission.reply", - summary: "Reply to pending permission request", - description: "Respond to a pending permission request owned by a session.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) diff --git a/packages/server/src/groups/question.ts b/packages/server/src/groups/question.ts deleted file mode 100644 index cb8932129e7..00000000000 --- a/packages/server/src/groups/question.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { QuestionV2 } from "@opencode-ai/core/question" -import { Location } from "@opencode-ai/core/location" -import { SessionV2 } from "@opencode-ai/core/session" -import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { QuestionNotFoundError, SessionNotFoundError } from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" - -export const QuestionGroup = HttpApiGroup.make("server.question") - .add( - HttpApiEndpoint.get("question.request.list", "/api/question/request", { - query: LocationQuery, - success: Location.response(Schema.Array(QuestionV2.Request)), - }) - .annotateMerge(locationQueryOpenApi) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.question.request.list", - summary: "List pending question requests", - description: "Retrieve pending question requests for a location.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." })) - .middleware(LocationMiddleware) - .add( - HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }), - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.question.list", - summary: "List session question requests", - description: "Retrieve pending question requests owned by a session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", { - params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID }, - payload: QuestionV2.Reply, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, QuestionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.question.reply", - summary: "Reply to pending question request", - description: "Answer a pending question request owned by a session.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", { - params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID }, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, QuestionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.question.reject", - summary: "Reject pending question request", - description: "Reject a pending question request owned by a session.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }), - ) diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts deleted file mode 100644 index 4fac946fdcd..00000000000 --- a/packages/server/src/groups/session.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { SessionMessage } from "@opencode-ai/core/session/message" -import { SessionInput } from "@opencode-ai/core/session/input" -import { Prompt } from "@opencode-ai/core/session/prompt" -import { SessionV2 } from "@opencode-ai/core/session" -import { ProjectV2 } from "@opencode-ai/core/project" -import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema" -import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Schema, Struct } from "effect" -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { - ConflictError, - InvalidCursorError, - InvalidRequestError, - ServiceUnavailableError, - SessionNotFoundError, - UnknownError, -} from "../errors" -import { SessionLocationMiddleware } from "../middleware/session-location" -import { AgentV2 } from "@opencode-ai/core/agent" -import { ModelV2 } from "@opencode-ai/core/model" -import { Location } from "@opencode-ai/core/location" -import { LocationMiddleware } from "./location" // kilocode_change - -const SessionsQueryFields = { - workspace: WorkspaceV2.ID.pipe(Schema.optional), - limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - search: Schema.optional(Schema.String), -} - -const SessionsDirectoryQuery = Schema.Struct({ - ...SessionsQueryFields, - directory: AbsolutePath, -}) - -const SessionsProjectQuery = Schema.Struct({ - ...SessionsQueryFields, - project: ProjectV2.ID, - subpath: RelativePath.pipe(Schema.optional), -}) - -const SessionsAllQuery = Schema.Struct(SessionsQueryFields) - -const withCursor = (schema: Schema.Struct) => - schema.mapFields((fields) => ({ - ...Struct.omit(fields, ["limit"]), - anchor: SessionV2.ListAnchor, - })) - -const SessionsCursorInput = Schema.Union([ - withCursor(SessionsDirectoryQuery), - withCursor(SessionsProjectQuery), - withCursor(SessionsAllQuery), -]) -const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) -const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) -const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) - -export const SessionsCursor = Schema.String.pipe( - Schema.brand("SessionsCursor"), - withStatics((schema) => { - const make = schema.make - return { - make: (input: typeof SessionsCursorInput.Type) => - make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")), - parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")), - } - }), -) -export type SessionsCursor = typeof SessionsCursor.Type - -const SessionsCursorQuery = Schema.Struct({ - cursor: SessionsCursor.annotate({ - description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", - }), - limit: SessionsQueryFields.limit, -}) - -export const SessionsQuery = Schema.Struct({ - ...SessionsQueryFields, - directory: AbsolutePath.pipe(Schema.optional), - project: ProjectV2.ID.pipe(Schema.optional), - subpath: RelativePath.pipe(Schema.optional), - cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional), -}).annotate({ identifier: "SessionsQuery" }) - -export const SessionGroup = HttpApiGroup.make("server.session") - .add( - HttpApiEndpoint.get("session.list", "/api/session", { - query: SessionsQuery, - success: Schema.Struct({ - data: Schema.Array(SessionV2.Info), - cursor: Schema.Struct({ - previous: SessionsCursor.pipe(Schema.optional), - next: SessionsCursor.pipe(Schema.optional), - }), - }).annotate({ identifier: "SessionsResponse" }), - error: [InvalidCursorError, InvalidRequestError], - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.list", - summary: "List sessions", - description: - "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.create", "/api/session", { - payload: Schema.Struct({ - id: SessionV2.ID.pipe(Schema.optional), - agent: AgentV2.ID.pipe(Schema.optional), - model: ModelV2.Ref.pipe(Schema.optional), - location: Location.Ref.pipe(Schema.optional), - }), - success: Schema.Struct({ data: SessionV2.Info }), - }) - .middleware(LocationMiddleware) // kilocode_change - use the SDK's configured directory when payload location is omitted - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.create", - summary: "Create session", - description: "Create a session at the requested location.", - }), - ), - ) - .add( - HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: SessionV2.Info }), - error: SessionNotFoundError, - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.get", - summary: "Get session", - description: "Retrieve a session by ID.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { - params: { sessionID: SessionV2.ID }, - payload: Schema.Struct({ - id: SessionMessage.ID.pipe(Schema.optional), - prompt: Prompt, - delivery: SessionInput.Delivery.pipe(Schema.optional), - resume: Schema.Boolean.pipe(Schema.optional), - }), - success: Schema.Struct({ data: SessionInput.Admitted }), - error: [ConflictError, SessionNotFoundError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.prompt", - summary: "Send message", - description: "Durably admit one session input and schedule agent-loop execution unless resume is false.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { - params: { sessionID: SessionV2.ID }, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, ServiceUnavailableError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.compact", - summary: "Compact session", - description: "Compact a session conversation.", - }), - ), - ) - .add( - HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { - params: { sessionID: SessionV2.ID }, - success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, ServiceUnavailableError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.wait", - summary: "Wait for session", - description: "Wait for a session agent loop to become idle.", - }), - ), - ) - .add( - HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { - params: { sessionID: SessionV2.ID }, - success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), - error: [SessionNotFoundError, UnknownError], - }) - .middleware(SessionLocationMiddleware) - .annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.context", - summary: "Get session context", - description: "Retrieve the active context messages for a session (all messages after the last compaction).", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "sessions", - description: "Experimental session routes.", - }), - ) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 1d12dde5505..ad64644d063 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -1,9 +1,5 @@ -import { SessionV2 } from "@opencode-ai/core/session" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { PtyTicket } from "@opencode-ai/core/pty/ticket" + import { Layer } from "effect" -import { layer as locationLayer } from "./groups/location" -import { sessionLocationLayer } from "./middleware/session-location" import { MessageHandler } from "./handlers/message" import { ModelHandler } from "./handlers/model" import { ProviderHandler } from "./handlers/provider" @@ -18,11 +14,9 @@ import { HealthHandler } from "./handlers/health" import { PtyHandler } from "./handlers/pty" import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" -import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" -import { Credential } from "@opencode-ai/core/credential" import { ProjectCopyHandler } from "./handlers/project-copy" export const handlers = Layer.mergeAll( @@ -44,13 +38,4 @@ export const handlers = Layer.mergeAll( QuestionHandler, ReferenceHandler, ProjectCopyHandler, -).pipe( - Layer.provide(sessionLocationLayer), - Layer.provide(locationLayer), - Layer.provide(SessionV2.defaultLayer), - Layer.provide(SessionExecutionLocal.defaultLayer), - Layer.provide(PermissionSaved.defaultLayer), - Layer.provide(PtyTicket.defaultLayer), - // kilocode_change - the host provides LocationServiceMap so Kilo can install effective-reference initialization - Layer.provide(Credential.defaultLayer), ) diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index cbde76dbce3..c1511e3c62c 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -1,14 +1,12 @@ import { AgentV2 } from "@opencode-ai/core/agent" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) => handlers.handle("agent.list", () => Effect.gen(function* () { - yield* PluginBoot.Service.use((plugin) => plugin.wait()) return yield* response(AgentV2.Service.use((agent) => agent.all())) }), ), diff --git a/packages/server/src/handlers/command.ts b/packages/server/src/handlers/command.ts index b09d52bee93..bf41e79f835 100644 --- a/packages/server/src/handlers/command.ts +++ b/packages/server/src/handlers/command.ts @@ -1,8 +1,7 @@ import { CommandV2 } from "@opencode-ai/core/command" -import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) => handlers.handle("command.list", () => response(CommandV2.Service.use((command) => command.list()))), diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 77361a400c0..8d89cbcd196 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -1,17 +1,19 @@ import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { Effect, Stream } from "effect" +import { KiloEvent } from "@opencode-ai/protocol/groups/event" // kilocode_change - encode the full Kilo event bus +import { Effect, Schema, Stream } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" import { Api } from "../api" +const subscriberCapacity = 256 + function eventData(data: unknown): Sse.Event { return { _tag: "Event", event: "message", id: undefined, - data: JSON.stringify(data), + data: JSON.stringify(Schema.encodeUnknownSync(KiloEvent)(data)), // kilocode_change } } @@ -20,41 +22,21 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) const events = yield* EventV2.Service return handlers.handleRaw("event.subscribe", () => Effect.gen(function* () { - const location = yield* Location.Service const connected = { id: EventV2.ID.create(), type: "server.connected", - location: new Location.Info({ - directory: location.directory, - workspaceID: location.workspaceID, - project: location.project, - }), data: {}, } + const output = Stream.unwrap( + Effect.gen(function* () { + // Acquiring the bounded stream installs its listener before readiness is observable. + const live = yield* EventV2.allBounded(events, subscriberCapacity) + return Stream.make(connected).pipe(Stream.concat(live)) + }), + ).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode())) + const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n")) return HttpServerResponse.stream( - Stream.make(connected).pipe( - Stream.concat( - events.all().pipe( - Stream.filter( - (event) => - event.location?.directory === location.directory && - event.location.workspaceID === location.workspaceID, - ), - // kilocode_change - Kilo's shared EventV2 service includes core events carrying Location.Ref - Stream.map((event) => ({ - ...event, - location: new Location.Info({ - directory: location.directory, - workspaceID: location.workspaceID, - project: location.project, - }), - })), - ), - ), - Stream.map(eventData), - Stream.pipeThroughChannel(Sse.encode()), - Stream.encodeText, - ), + output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText), { contentType: "text/event-stream", headers: { diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index 811addaff7b..19f94e59ef2 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -4,7 +4,7 @@ import { Effect } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) => Effect.gen(function* () { diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index d7c651e847a..6c29d587760 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -2,8 +2,8 @@ import { Integration } from "@opencode-ai/core/integration" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { InvalidRequestError } from "../errors" -import { response } from "../groups/location" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" +import { response } from "../location" const authorize = (effect: Effect.Effect) => effect.pipe( diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index eb5758c2472..93734c628d8 100644 --- a/packages/server/src/handlers/message.ts +++ b/packages/server/src/handlers/message.ts @@ -3,7 +3,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Effect, Schema } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors" +import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors" const DefaultMessagesLimit = 50 @@ -60,10 +60,7 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), Effect.andThen( Effect.fail( - new UnknownError({ - message: "Unexpected server error. Check server logs for details.", - ref, - }), + new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }), ), ), ) diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 71a3f9d8500..36639ae7b1e 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -1,15 +1,8 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { ServiceUnavailableError } from "../errors" -import { response } from "../groups/location" - -const catalogUnavailable = new ServiceUnavailableError({ - message: "Model catalog is unavailable", - service: "catalog", -}) +import { response } from "../location" export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => Effect.gen(function* () { @@ -17,8 +10,6 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) "model.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* response(catalog.model.available()) }), ) diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index 34fa3d428e5..0425c149961 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -4,8 +4,8 @@ import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { PermissionNotFoundError } from "../errors" -import { response } from "../groups/location" +import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" function missingRequest(id: PermissionV2.ID) { return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) @@ -20,6 +20,35 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", return yield* response((yield* PermissionV2.Service).list()) }), ) + .handle( + "session.permission.create", + Effect.fn(function* (ctx) { + const permission = yield* PermissionV2.Service + return { + data: yield* permission + .ask({ + id: ctx.payload.id, + sessionID: ctx.params.sessionID, + action: ctx.payload.action, + resources: ctx.payload.resources, + save: ctx.payload.save, + metadata: ctx.payload.metadata, + source: ctx.payload.source, + agent: ctx.payload.agent, + }) + .pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + } + }), + ) .handle( "session.permission.list", Effect.fn(function* (ctx) { @@ -27,6 +56,14 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", return { data: yield* permission.forSession(ctx.params.sessionID) } }), ) + .handle( + "session.permission.get", + Effect.fn(function* (ctx) { + const request = yield* (yield* PermissionV2.Service).get(ctx.params.requestID) + if (!request || request.sessionID !== ctx.params.sessionID) return yield* missingRequest(ctx.params.requestID) + return { data: request } + }), + ) .handle( "session.permission.reply", Effect.fn(function* (ctx) { diff --git a/packages/server/src/handlers/project-copy.ts b/packages/server/src/handlers/project-copy.ts index 91b48fb1d57..3733db6771a 100644 --- a/packages/server/src/handlers/project-copy.ts +++ b/packages/server/src/handlers/project-copy.ts @@ -4,7 +4,7 @@ import { Git } from "@opencode-ai/core/git" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { ProjectCopyError } from "../groups/project-copy" +import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy" export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) => Effect.succeed( diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index 81e5e93ac79..c3f25ab0dfc 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -1,16 +1,9 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" -import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { ProviderNotFoundError, ServiceUnavailableError } from "../errors" -import { response } from "../groups/location" - -const catalogUnavailable = new ServiceUnavailableError({ - message: "Provider catalog is unavailable", - service: "catalog", -}) +import { ProviderNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => Effect.gen(function* () { @@ -19,8 +12,6 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* response(catalog.provider.available()) }), ) @@ -28,18 +19,13 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.get", Effect.fn(function* (ctx) { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) - return yield* response(catalog.provider.get(ctx.params.providerID)).pipe( - Effect.catchTag("CatalogV2.ProviderNotFound", (error) => - Effect.fail( - new ProviderNotFoundError({ - providerID: error.providerID, - message: `Provider not found: ${error.providerID}`, - }), - ), - ), - ) + const provider = yield* catalog.provider.get(ctx.params.providerID) + if (!provider) + return yield* new ProviderNotFoundError({ + providerID: ctx.params.providerID, + message: `Provider not found: ${ctx.params.providerID}`, + }) + return yield* response(Effect.succeed(provider)) }), ) }), diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts index bcb675e12d3..abf7021407d 100644 --- a/packages/server/src/handlers/pty.ts +++ b/packages/server/src/handlers/pty.ts @@ -8,14 +8,14 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { Api } from "../api" import { CorsConfig, isAllowedRequestOrigin } from "../cors" -import { ForbiddenError, PtyNotFoundError } from "../errors" +import { ForbiddenError, PtyNotFoundError } from "@opencode-ai/protocol/errors" import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE, PTY_REPLAY_EXITED_QUERY, -} from "../groups/pty" -import { response } from "../groups/location" +} from "@opencode-ai/protocol/groups/pty" +import { response } from "../location" import { PtyEnvironment } from "../pty-environment" const ticketScope = Effect.gen(function* () { diff --git a/packages/server/src/handlers/question.ts b/packages/server/src/handlers/question.ts index 151557c508d..954afe0df58 100644 --- a/packages/server/src/handlers/question.ts +++ b/packages/server/src/handlers/question.ts @@ -2,8 +2,8 @@ import { QuestionV2 } from "@opencode-ai/core/question" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { QuestionNotFoundError } from "../errors" -import { response } from "../groups/location" +import { QuestionNotFoundError } from "@opencode-ai/protocol/errors" +import { response } from "../location" function missingRequest(id: QuestionV2.ID) { return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` }) diff --git a/packages/server/src/handlers/reference.ts b/packages/server/src/handlers/reference.ts index d1298f42e1a..6bc12fd8e58 100644 --- a/packages/server/src/handlers/reference.ts +++ b/packages/server/src/handlers/reference.ts @@ -1,7 +1,7 @@ import { Reference } from "@opencode-ai/core/reference" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" import { reconcile } from "../kilocode/reference-reconciler" // kilocode_change export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) => diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 17cd83a064d..92e6544d29d 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,18 +1,20 @@ import { SessionV2 } from "@opencode-ai/core/session" -import { DateTime, Effect } from "effect" +import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { SessionsCursor } from "../groups/session" +import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, InvalidCursorError, + MessageNotFoundError, ServiceUnavailableError, SessionNotFoundError, UnknownError, -} from "../errors" +} from "@opencode-ai/protocol/errors" import { Location } from "@opencode-ai/core/location" // kilocode_change const DefaultSessionsLimit = 50 +const DefaultSessionHistoryLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { @@ -80,6 +82,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.active", + Effect.fn(function* () { + return { + data: Object.fromEntries( + Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]), + ), + } + }), + ) .handle( "session.get", Effect.fn(function* (ctx) { @@ -97,6 +109,38 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.switchAgent", + Effect.fn(function* (ctx) { + yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.switchModel", + Effect.fn(function* (ctx) { + yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.prompt", Effect.fn(function* (ctx) { @@ -178,6 +222,90 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.revert.stage", + Effect.fn(function* (ctx) { + return { + data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + Effect.catchTag( + "Session.MessageNotFoundError", + (error) => + new MessageNotFoundError({ + sessionID: error.sessionID, + messageID: error.messageID, + message: `Message not found: ${error.messageID}`, + }), + ), + Effect.catchTag("Snapshot.Error", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to stage session revert", { cause: error }).pipe( + Effect.andThen( + Effect.fail( + new UnknownError({ + message: "Unexpected server error. Check server logs for details.", + ref, + }), + ), + ), + ) + }), + ), + } + }), + ) + .handle( + "session.revert.clear", + Effect.fn(function* (ctx) { + yield* session.revert.clear(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + Effect.catchTag("Snapshot.Error", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to clear session revert", { cause: error }).pipe( + Effect.andThen( + Effect.fail( + new UnknownError({ + message: "Unexpected server error. Check server logs for details.", + ref, + }), + ), + ), + ) + }), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.revert.commit", + Effect.fn(function* (ctx) { + yield* session.revert.commit(ctx.params.sessionID).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.context", Effect.fn(function* (ctx) { @@ -197,10 +325,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), Effect.andThen( Effect.fail( - new UnknownError({ - message: "Unexpected server error. Check server logs for details.", - ref, - }), + new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }), ), ), ) @@ -209,5 +334,57 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.history", + Effect.fn(function* (ctx) { + return yield* session + .history({ + sessionID: ctx.params.sessionID, + after: ctx.query.after, + limit: ctx.query.limit ?? DefaultSessionHistoryLimit, + }) + .pipe( + Effect.map((page) => ({ + data: page.events, + hasMore: page.hasMore, + })), + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + }), + ) + .handle( + "session.events", + Effect.fn((ctx) => + Effect.succeed( + session.events({ sessionID: ctx.params.sessionID, after: ctx.query.after }).pipe(Stream.orDie), + ), + ), + ) + .handle( + "session.interrupt", + Effect.fn(function* (ctx) { + yield* session.interrupt(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.message", + Effect.fn(function* (ctx) { + const message = yield* session.message(ctx.params) + if (message) return { data: message } + return yield* new MessageNotFoundError({ + sessionID: ctx.params.sessionID, + messageID: ctx.params.messageID, + message: `Message not found: ${ctx.params.messageID}`, + }) + }), + ) }), ) diff --git a/packages/server/src/handlers/skill.ts b/packages/server/src/handlers/skill.ts index cf3f92c2a85..8ffeaca8ea2 100644 --- a/packages/server/src/handlers/skill.ts +++ b/packages/server/src/handlers/skill.ts @@ -1,7 +1,7 @@ import { SkillV2 } from "@opencode-ai/core/skill" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { response } from "../groups/location" +import { response } from "../location" export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) => handlers.handle("skill.list", () => response(SkillV2.Service.use((skill) => skill.list()))), diff --git a/packages/server/src/kilocode/reference-reconciler.ts b/packages/server/src/kilocode/reference-reconciler.ts index cb1ea039176..34ecd992cf7 100644 --- a/packages/server/src/kilocode/reference-reconciler.ts +++ b/packages/server/src/kilocode/reference-reconciler.ts @@ -1,11 +1,11 @@ import { Location } from "@opencode-ai/core/location" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { PluginV2 } from "@opencode-ai/core/plugin" import { Reference } from "@opencode-ai/core/reference" import { Context, Effect, Layer } from "effect" export class ReferenceReconciler extends Context.Service< ReferenceReconciler, - Effect.Effect + Effect.Effect >()("@kilocode/ReferenceReconciler") {} export const noop = Layer.succeed(ReferenceReconciler, Effect.void) diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts new file mode 100644 index 00000000000..f13f81263be --- /dev/null +++ b/packages/server/src/location.ts @@ -0,0 +1,62 @@ +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { Effect, Layer } from "effect" +import { HttpServerRequest } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" // kilocode_change + +export type LocationServices = Layer.Success> + +export class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiLocation", + { error: InvalidRequestError }, // kilocode_change - surface malformed headers as 400s +) {} + +export function response(data: Effect.Effect) { + return Effect.gen(function* () { + const location = yield* Location.Service + return { + location: new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }), + data: yield* data, + } + }) +} + +function ref(request: HttpServerRequest.HttpServerRequest) { + const query = new URL(request.url, "http://localhost").searchParams + const workspaceID = query.get("location[workspace]") || request.headers["x-kilo-workspace"] + const header = request.headers["x-kilo-directory"] + // kilocode_change start - reject malformed encoded directory headers as client errors + return Effect.try({ + try: () => query.get("location[directory]") || (header ? decodeURIComponent(header) : process.cwd()), + catch: () => new InvalidRequestError({ message: "Invalid encoded directory header", field: "x-kilo-directory" }), + }).pipe( + Effect.map((directory) => + Location.Ref.make({ + directory: AbsolutePath.make(directory), + workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined, + }), + ), + ) + // kilocode_change end +} + +export const layer = Layer.effect( + LocationMiddleware, + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + return LocationMiddleware.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const location = yield* ref(request) // kilocode_change - reject malformed encoded directory headers as 400s + return yield* effect.pipe(Effect.provide(locations.get(location))) + }), + ) + }), +) diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts index 5a8dae205db..cc785ee2472 100644 --- a/packages/server/src/middleware/authorization.ts +++ b/packages/server/src/middleware/authorization.ts @@ -1,17 +1,14 @@ import { ServerAuth } from "../auth" -import { UnauthorizedError } from "../errors" -import { hasPtyConnectTicketURL } from "../groups/pty" +import { UnauthorizedError } from "@opencode-ai/protocol/errors" +import { Authorization } from "@opencode-ai/protocol/middleware/authorization" +export { Authorization } from "@opencode-ai/protocol/middleware/authorization" +import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiMiddleware } from "effect/unstable/httpapi" const AUTH_TOKEN_QUERY = "auth_token" const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' -export class Authorization extends HttpApiMiddleware.Service()("@opencode/HttpApiAuthorization", { - error: UnauthorizedError, -}) {} - function emptyCredential() { return { username: "", password: Redacted.make("") } } diff --git a/packages/server/src/middleware/schema-error.ts b/packages/server/src/middleware/schema-error.ts index 8061a524bde..37013285b9d 100644 --- a/packages/server/src/middleware/schema-error.ts +++ b/packages/server/src/middleware/schema-error.ts @@ -1,6 +1,8 @@ import { Effect } from "effect" import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { InvalidRequestError } from "../errors" +import { InvalidRequestError } from "@opencode-ai/protocol/errors" +import { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error" +export { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error" const REASON_LIMIT = 1024 @@ -9,11 +11,6 @@ function truncateReason(reason: string) { return reason.slice(0, REASON_LIMIT) + `... (${reason.length - REASON_LIMIT} more chars)` } -export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( - "@opencode/HttpApiSchemaError", - { error: InvalidRequestError }, -) {} - export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { const reason = truncateReason(error.cause.message) return Effect.logWarning("schema rejection").pipe( diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index 7306cf76b86..86fa80f75c1 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -1,5 +1,5 @@ import { Database } from "@opencode-ai/core/database/database" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -9,14 +9,12 @@ import { eq } from "drizzle-orm" import { Effect, Layer, Schema } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { InvalidRequestError, SessionNotFoundError } from "../errors" -import type { LocationServices } from "../groups/location" +import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" +import type { LocationServices } from "../location" export class SessionLocationMiddleware extends HttpApiMiddleware.Service< SessionLocationMiddleware, - { - provides: LocationServices - } + { provides: LocationServices } >()("@opencode/HttpApiSessionLocation", { error: [InvalidRequestError, SessionNotFoundError], }) {} @@ -27,7 +25,7 @@ export const sessionLocationLayer = Layer.effect( SessionLocationMiddleware, Effect.gen(function* () { const { db } = yield* Database.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service return SessionLocationMiddleware.of((effect) => Effect.gen(function* () { diff --git a/packages/server/src/pty-environment.ts b/packages/server/src/pty-environment.ts index fe3a375cdc8..3e18a6f9258 100644 --- a/packages/server/src/pty-environment.ts +++ b/packages/server/src/pty-environment.ts @@ -1,6 +1,7 @@ export * as PtyEnvironment from "./pty-environment" import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" export interface Interface { readonly get: (input: { directory: string; cwd: string }) => Effect.Effect> @@ -8,9 +9,11 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ServerPtyEnvironment") {} -export const defaultLayer = Layer.succeed( +export const layer = Layer.succeed( Service, Service.of({ get: () => Effect.succeed({}), }), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 2563c9a806e..164f1458247 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,33 +1,66 @@ import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" +import { Credential } from "@opencode-ai/core/credential" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" import { Api } from "./api" import { ServerAuth } from "./auth" import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" +import * as ReferenceReconciler from "./kilocode/reference-reconciler" // kilocode_change import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" -import { noop as referenceNoop } from "./kilocode/reference-reconciler" // kilocode_change +import { layer as locationLayer } from "./location" +import { sessionLocationLayer } from "./middleware/session-location" + +const applicationServices = LayerNode.group([ + Database.node, + EventV2.node, + httpClient, + ToolOutputStore.cleanupNode, + SessionV2.node, + PermissionSaved.node, + PtyTicket.node, + Credential.node, + PtyEnvironment.node, + LocationServiceMap.node, +]) export function createRoutes(password?: string) { + return makeRoutes( + password + ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) + : ServerAuth.Config.layer, + ) +} + +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) +} + +function makeRoutes(auth: Layer.Layer) { + const serviceLayer = AppNodeBuilder.build(applicationServices, [[SessionExecution.node, SessionExecutionLocal.node]]) + return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), - Layer.provide(PtyEnvironment.defaultLayer), - Layer.provide(referenceNoop), // kilocode_change - standalone server has no Kilo config reconciler + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), // kilocode_change - standalone server has no Kilo config reconciler Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), - Layer.provide( - password - ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) - : ServerAuth.Config.defaultLayer, - ), - Layer.provide(LocationServiceMap.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(FetchHttpClient.layer), + Layer.provide(auth), + Layer.provide(serviceLayer), + HttpRouter.provideRequest(ReferenceReconciler.noop), // kilocode_change - request-scoped; no Kilo reconciler outside the CLI ) } diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json new file mode 100644 index 00000000000..ab3353ae089 --- /dev/null +++ b/packages/session-ui/package.json @@ -0,0 +1,68 @@ +{ + "name": "@opencode-ai/session-ui", + "version": "7.4.16", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + "./*": "./src/components/*.tsx", + "./session-diff": "./src/components/session-diff.ts", + "./message-file": "./src/components/message-file.ts", + "./message-part-text": "./src/components/message-part-text.ts", + "./markdown-stream": "./src/components/markdown-stream.ts", + "./markdown-cache": "./src/components/markdown-cache.tsx", + "./line-comment-styles": "./src/components/line-comment-styles.ts", + "./pierre": "./src/pierre/index.ts", + "./pierre/*": "./src/pierre/*.ts", + "./context": "./src/context/index.ts", + "./context/*": "./src/context/*.tsx", + "./styles": "./src/styles/index.css", + "./v2/*.css": "./src/v2/components/*.css", + "./v2/*": "./src/v2/components/*.tsx" + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "bun test src --only-failures", + "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@types/katex": "0.16.7", + "@types/luxon": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + }, + "dependencies": { + "@kobalte/core": "catalog:", + "@opencode-ai/core": "workspace:*", + "@kilocode/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", + "@shikijs/transformers": "3.9.2", + "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/event-listener": "2.4.5", + "@solid-primitives/media": "2.3.3", + "@solid-primitives/resize-observer": "2.1.3", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "diff": "catalog:", + "dompurify": "3.3.1", + "fuzzysort": "catalog:", + "katex": "0.16.27", + "luxon": "catalog:", + "marked": "catalog:", + "marked-katex-extension": "5.1.6", + "marked-shiki": "catalog:", + "morphdom": "2.7.8", + "motion": "12.34.5", + "remeda": "catalog:", + "remend": "catalog:", + "shiki": "catalog:", + "solid-js": "catalog:", + "solid-list": "catalog:", + "strip-ansi": "7.1.2" + } +} diff --git a/packages/session-ui/src/components/apply-patch-file.test.ts b/packages/session-ui/src/components/apply-patch-file.test.ts new file mode 100644 index 00000000000..f7a8e77881a --- /dev/null +++ b/packages/session-ui/src/components/apply-patch-file.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { patchFiles } from "./apply-patch-file" +import { text } from "./session-diff" + +describe("apply patch file", () => { + test("parses patch metadata from the server", () => { + const file = patchFiles([ + { + filePath: "/tmp/a.ts", + relativePath: "a.ts", + type: "update", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n", + additions: 1, + deletions: 1, + }, + ])[0] + + expect(file).toBeDefined() + expect(file?.view.fileDiff.name).toBe("a.ts") + expect(file?.view.fileDiff.isPartial).toBe(false) + expect(text(file!.view, "deletions")).toBe("one\ntwo\n") + expect(text(file!.view, "additions")).toBe("one\nthree\n") + }) + + test("keeps legacy before and after payloads working", () => { + const file = patchFiles([ + { + filePath: "/tmp/a.ts", + relativePath: "a.ts", + type: "update", + before: "one\n", + after: "two\n", + additions: 1, + deletions: 1, + }, + ])[0] + + expect(file).toBeDefined() + expect(text(file!.view, "deletions")).toBe("one\n") + expect(text(file!.view, "additions")).toBe("two\n") + }) +}) diff --git a/packages/session-ui/src/components/apply-patch-file.ts b/packages/session-ui/src/components/apply-patch-file.ts new file mode 100644 index 00000000000..8e0c5408260 --- /dev/null +++ b/packages/session-ui/src/components/apply-patch-file.ts @@ -0,0 +1,78 @@ +import { normalize, type ViewDiff } from "./session-diff" + +type Kind = "add" | "update" | "delete" | "move" + +type Raw = { + filePath?: string + relativePath?: string + type?: Kind + patch?: string + diff?: string + before?: string + after?: string + additions?: number + deletions?: number + movePath?: string +} + +export type ApplyPatchFile = { + filePath: string + relativePath: string + type: Kind + additions: number + deletions: number + movePath?: string + view: ViewDiff +} + +function kind(value: unknown) { + if (value === "add" || value === "update" || value === "delete" || value === "move") return value +} + +function status(type: Kind): "added" | "deleted" | "modified" { + if (type === "add") return "added" + if (type === "delete") return "deleted" + return "modified" +} + +export function patchFile(raw: unknown): ApplyPatchFile | undefined { + if (!raw || typeof raw !== "object") return + + const value = raw as Raw + const type = kind(value.type) + const filePath = typeof value.filePath === "string" ? value.filePath : undefined + const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath + const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined + const before = typeof value.before === "string" ? value.before : undefined + const after = typeof value.after === "string" ? value.after : undefined + + if (!type || !filePath || !relativePath) return + if (!patch && before === undefined && after === undefined) return + + const additions = typeof value.additions === "number" ? value.additions : 0 + const deletions = typeof value.deletions === "number" ? value.deletions : 0 + const movePath = typeof value.movePath === "string" ? value.movePath : undefined + + return { + filePath, + relativePath, + type, + additions, + deletions, + movePath, + view: normalize({ + file: relativePath, + patch, + before, + after, + additions, + deletions, + status: status(type), + }), + } +} + +export function patchFiles(raw: unknown) { + if (!Array.isArray(raw)) return [] + return raw.map(patchFile).filter((file): file is ApplyPatchFile => !!file) +} diff --git a/packages/session-ui/src/components/basic-tool.css b/packages/session-ui/src/components/basic-tool.css new file mode 100644 index 00000000000..7df483afe65 --- /dev/null +++ b/packages/session-ui/src/components/basic-tool.css @@ -0,0 +1,279 @@ +[data-component="tool-trigger"] { + content-visibility: auto; + width: 100%; + display: flex; + align-items: center; + align-self: stretch; + gap: 0px; + justify-content: flex-start; + + &[data-clickable="true"] { + cursor: pointer; + } + + [data-slot="basic-tool-tool-trigger-content"] { + flex: 0 1 auto; + width: auto; + max-width: calc(100% - 24px); + min-width: 0; + display: flex; + align-items: center; + align-self: stretch; + gap: 8px; + } + + [data-slot="basic-tool-tool-indicator"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-slot="basic-tool-tool-spinner"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--v2-text-text-faint); + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-slot="icon-svg"] { + flex-shrink: 0; + } + + [data-slot="basic-tool-tool-info"] { + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + font-size: 14px; + } + + [data-slot="basic-tool-tool-info-structured"] { + flex: 0 1 auto; + width: auto; + max-width: 100%; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + justify-content: flex-start; + } + + [data-slot="basic-tool-tool-info-main"] { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + overflow: hidden; + } + + [data-slot="basic-tool-tool-title"] { + flex-shrink: 0; + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--v2-text-text-base); + + &.capitalize { + text-transform: capitalize; + } + + &.agent-title { + color: var(--v2-text-text-base); + font-weight: var(--font-weight-medium); + } + } + + [data-slot="basic-tool-tool-subtitle"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-variant-numeric: tabular-nums; + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--v2-text-text-muted); + + &.clickable { + cursor: pointer; + text-decoration: underline; + transition: color 0.15s ease; + + &:hover { + color: var(--v2-text-text-muted); + } + } + + &.subagent-link { + color: var(--text-interactive-base); + text-decoration: none; + text-underline-offset: 2px; + font-weight: var(--font-weight-regular); + + &:hover { + color: var(--text-interactive-base); + text-decoration: underline; + } + + &:active { + color: var(--text-interactive-base); + } + + &:visited { + color: var(--text-interactive-base); + } + } + } + + [data-slot="basic-tool-tool-arg"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-variant-numeric: tabular-nums; + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--v2-text-text-muted); + } + + [data-slot="basic-tool-tool-action"] { + display: inline-flex; + align-items: center; + flex-shrink: 0; + } +} + +[data-component="task-tool-card"] { + width: 100%; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + border: 0.5px solid var(--border-weak-base, rgba(255, 255, 255, 0.08)); + background: color-mix(in srgb, var(--background-base) 92%, transparent); + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; + + [data-slot="basic-tool-tool-info-structured"] { + flex: 1 1 auto; + min-width: 0; + } + + [data-slot="basic-tool-tool-info-main"] { + flex: 1 1 auto; + min-width: 0; + align-items: center; + } + + [data-component="task-tool-spinner"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-component="task-tool-action"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--v2-text-text-faint); + margin-left: auto; + opacity: 0; + transition: + opacity 0.15s ease, + color 0.15s ease; + } + + [data-component="task-tool-title"] { + flex-shrink: 0; + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + text-transform: capitalize; + } + + [data-slot="basic-tool-tool-subtitle"] { + color: var(--v2-text-text-base); + } + + &:hover, + &:focus-visible { + border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08)); + background: color-mix(in srgb, var(--background-stronger) 88%, transparent); + + [data-component="task-tool-action"] { + opacity: 1; + } + } +} + +body:not([data-new-layout]) { + [data-component="tool-trigger"] { + [data-slot="basic-tool-tool-spinner"] { + color: var(--text-weak); + } + + [data-slot="basic-tool-tool-title"], + [data-slot="basic-tool-tool-title"].agent-title { + color: var(--text-strong); + } + + [data-slot="basic-tool-tool-subtitle"], + [data-slot="basic-tool-tool-subtitle"].clickable:hover, + [data-slot="basic-tool-tool-arg"] { + color: var(--text-base); + } + } + + [data-component="task-tool-card"] { + border-width: 1px; + + [data-component="task-tool-action"] { + color: var(--icon-weak); + } + + [data-slot="basic-tool-tool-subtitle"] { + color: var(--text-strong); + } + } +} diff --git a/packages/session-ui/src/components/basic-tool.stories.tsx b/packages/session-ui/src/components/basic-tool.stories.tsx new file mode 100644 index 00000000000..5fb366c0168 --- /dev/null +++ b/packages/session-ui/src/components/basic-tool.stories.tsx @@ -0,0 +1,133 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import * as mod from "./basic-tool" +import { create } from "@opencode-ai/ui/storybook/scaffold" + +const docs = `### Overview +Expandable tool panel with a structured trigger and optional details. + +Use structured triggers for consistent layout; custom triggers allowed. + +### API +- Required: \`icon\` and \`trigger\` (structured or custom JSX). +- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`. + +### Variants and states +- Pending/running status animates the title via TextShimmer. + +### Behavior +- Uses Collapsible; can defer content rendering until open. +- Locked state prevents closing. + +### Accessibility +- TODO: confirm trigger semantics and aria labeling. + +### Theming/tokens +- Uses \`data-component="tool-trigger"\` and related slots. + +` + +const story = create({ + title: "UI/Basic Tool", + mod, + args: { + icon: "mcp", + defaultOpen: true, + trigger: { + title: "Basic Tool", + subtitle: "Example subtitle", + args: ["--flag", "value"], + }, + children: "Details content", + }, +}) + +export default { + title: "UI/Basic Tool", + id: "components-basic-tool", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Pending = { + args: { + status: "pending", + trigger: { + title: "Running tool", + subtitle: "Working...", + }, + children: "Progress details", + }, +} + +export const Locked = { + args: { + locked: true, + trigger: { + title: "Locked tool", + subtitle: "Cannot close", + }, + children: "Locked details", + }, +} + +export const Deferred = { + args: { + defer: true, + defaultOpen: false, + trigger: { + title: "Deferred tool", + subtitle: "Content mounts on open", + }, + children: "Deferred content", + }, +} + +export const ForceOpen = { + args: { + forceOpen: true, + trigger: { + title: "Forced open", + subtitle: "Cannot close", + }, + children: "Forced content", + }, +} + +export const HideDetails = { + args: { + hideDetails: true, + trigger: { + title: "Summary only", + subtitle: "Details hidden", + }, + children: "Hidden content", + }, +} + +export const SubtitleAction = { + render: () => { + const [message, setMessage] = createSignal("Subtitle not clicked") + return ( +
+
{message()}
+ setMessage("Subtitle clicked")} + > + Subtitle action details + +
+ ) + }, +} diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx new file mode 100644 index 00000000000..4f2a4ced6f8 --- /dev/null +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -0,0 +1,339 @@ +import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js" +import { animate, type AnimationPlaybackControls } from "motion" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { createStore } from "solid-js/store" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import type { IconProps } from "@opencode-ai/ui/icon" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" + +export type TriggerTitle = { + title: string + titleClass?: string + subtitle?: string + subtitleClass?: string + args?: string[] + argsClass?: string + action?: JSX.Element +} + +const isTriggerTitle = (val: any): val is TriggerTitle => { + return ( + typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node)) + ) +} + +export interface BasicToolProps { + icon: IconProps["name"] + trigger: TriggerTitle | JSX.Element | ((open: Accessor) => JSX.Element) + children?: JSX.Element + status?: string + hideDetails?: boolean + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + forceOpen?: boolean + defer?: boolean + locked?: boolean + animated?: boolean + onSubtitleClick?: () => void + onTriggerClick?: JSX.EventHandlerUnion + triggerHref?: string + clickable?: boolean +} + +const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } +const deferredMounts: Array<{ active: boolean; fn: () => void }> = [] +let deferredFrame: number | undefined + +function flushDeferredMounts() { + while (deferredMounts.length > 0) { + // Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn. + // Pop from the end so heavy default-open bodies near the bottom become interactive first. + const item = deferredMounts.pop()! + if (item.active) { + deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined + item.fn() + return + } + } + deferredFrame = undefined +} + +function scheduleDeferredFlush() { + if (deferredFrame !== undefined) return + deferredFrame = requestAnimationFrame(() => { + deferredFrame = requestAnimationFrame(flushDeferredMounts) + }) +} + +function scheduleDeferredMount(fn: () => void) { + const item = { active: true, fn } + deferredMounts.push(item) + scheduleDeferredFlush() + return () => { + item.active = false + } +} + +function scheduleFrameMount(fn: () => void) { + const frame = requestAnimationFrame(fn) + return () => cancelAnimationFrame(frame) +} + +export function BasicTool(props: BasicToolProps) { + const [state, setState] = createStore({ + open: props.defaultOpen ?? false, + ready: !props.defer && (props.defaultOpen ?? false), + }) + const open = () => props.open ?? state.open + const ready = () => state.ready + const pending = () => props.status === "pending" || props.status === "running" + const hasChildren = () => (props.defer ? "children" in props : props.children) + const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined + + let cancelReady: (() => void) | undefined + + const cancel = () => { + cancelReady?.() + cancelReady = undefined + } + + const scheduleReady = (initial = false) => { + cancel() + cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => { + cancelReady = undefined + if (!open()) return + setState("ready", true) + }) + } + + onCleanup(cancel) + + onMount(() => { + if (props.defer && open()) scheduleReady(true) + }) + + const setOpen = (value: boolean) => { + if (props.open === undefined) setState("open", value) + props.onOpenChange?.(value) + } + + createEffect(() => { + if (!props.forceOpen) return + if (open()) return + setOpen(true) + }) + + createEffect( + on( + open, + (value) => { + if (!props.defer) return + if (!value) { + cancel() + setState("ready", false) + return + } + + scheduleReady() + }, + { defer: true }, + ), + ) + + // Animated height for collapsible open/close + let contentRef: HTMLDivElement | undefined + let heightAnim: AnimationPlaybackControls | undefined + const initialOpen = open() + + createEffect( + on( + open, + (isOpen) => { + if (!props.animated || !contentRef) return + heightAnim?.stop() + if (isOpen) { + contentRef.style.overflow = "hidden" + heightAnim = animate(contentRef, { height: "auto" }, SPRING) + void heightAnim.finished.then(() => { + if (!contentRef || !open()) return + contentRef.style.overflow = "visible" + contentRef.style.height = "auto" + }) + } else { + contentRef.style.overflow = "hidden" + heightAnim = animate(contentRef, { height: "0px" }, SPRING) + } + }, + { defer: true }, + ), + ) + + onCleanup(() => { + heightAnim?.stop() + }) + + const handleOpenChange = (value: boolean) => { + if (pending()) return + if (props.locked && !value) return + setOpen(value) + } + + const trigger = () => ( +
+
+
+ + {dynamicTrigger} + + {(title) => ( +
+
+ + + + + + { + if (props.onSubtitleClick) { + e.stopPropagation() + props.onSubtitleClick() + } + }} + > + {title().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + + + +
+ + {title().action} + +
+ )} +
+ {props.trigger as JSX.Element} +
+
+
+ + + +
+ ) + + return ( + + + {trigger()} + + } + > + {(href) => ( + + {trigger()} + + )} + + +
+ {props.children} +
+
+ + + {props.children} + + +
+ ) +} + +function label(input: Record | undefined) { + const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"] + return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0) +} + +function args(input: Record | undefined) { + if (!input) return [] + const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"]) + return Object.entries(input) + .filter(([key]) => !skip.has(key)) + .flatMap(([key, value]) => { + if (typeof value === "string") return [`${key}=${value}`] + if (typeof value === "number") return [`${key}=${value}`] + if (typeof value === "boolean") return [`${key}=${value}`] + return [] + }) + .slice(0, 3) +} + +export function GenericTool(props: { + tool: string + status?: string + hideDetails?: boolean + input?: Record +}) { + const i18n = useI18n() + + return ( + + ) +} diff --git a/packages/session-ui/src/components/dock-prompt.stories.tsx b/packages/session-ui/src/components/dock-prompt.stories.tsx new file mode 100644 index 00000000000..f75b04bc21d --- /dev/null +++ b/packages/session-ui/src/components/dock-prompt.stories.tsx @@ -0,0 +1,62 @@ +// @ts-nocheck +import * as mod from "./dock-prompt" +import { create } from "@opencode-ai/ui/storybook/scaffold" + +const docs = `### Overview +Docked prompt layout for questions and permission requests. + +Use with form controls or confirmation buttons in the footer. + +### API +- Required: \`kind\` (question | permission), \`header\`, \`children\`, \`footer\`. +- Optional: \`ref\` for measuring or focus management. + +### Variants and states +- Question and permission layouts (data attributes). + +### Behavior +- Pure layout component; behavior handled by parent. + +### Accessibility +- Ensure header and footer content provide clear context and actions. + +### Theming/tokens +- Uses \`data-component="dock-prompt"\` with kind data attribute. + +` + +const story = create({ + title: "UI/DockPrompt", + mod, + args: { + kind: "question", + header: "Header", + children: "Prompt content", + footer: "Footer", + }, +}) + +export default { + title: "UI/DockPrompt", + id: "components-dock-prompt", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Permission = { + args: { + kind: "permission", + header: "Allow access?", + children: "This action needs permission to proceed.", + footer: "Approve or deny", + }, +} diff --git a/packages/session-ui/src/components/dock-prompt.tsx b/packages/session-ui/src/components/dock-prompt.tsx new file mode 100644 index 00000000000..75563e47b86 --- /dev/null +++ b/packages/session-ui/src/components/dock-prompt.tsx @@ -0,0 +1,23 @@ +import type { JSX } from "solid-js" +import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface" + +export function DockPrompt(props: { + kind: "question" | "permission" + header: JSX.Element + children: JSX.Element + footer: JSX.Element + ref?: (el: HTMLDivElement) => void + onKeyDown?: JSX.EventHandlerUnion +}) { + const slot = (name: string) => `${props.kind}-${name}` + + return ( +
+ +
{props.header}
+
{props.children}
+
+ {props.footer} +
+ ) +} diff --git a/packages/session-ui/src/components/file-media.tsx b/packages/session-ui/src/components/file-media.tsx new file mode 100644 index 00000000000..f4dbb40a2f7 --- /dev/null +++ b/packages/session-ui/src/components/file-media.tsx @@ -0,0 +1,267 @@ +import type { FileContent } from "@kilocode/sdk/v2" +import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { + dataUrlFromMediaValue, + hasMediaValue, + isBinaryContent, + mediaKindFromPath, + normalizeMimeType, + svgTextFromValue, +} from "../pierre/media" + +export type FileMediaOptions = { + mode?: "auto" | "off" + path?: string + current?: unknown + before?: unknown + after?: unknown + deleted?: boolean + readFile?: (path: string) => Promise + onLoad?: () => void + onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void +} + +function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") { + if (cfg.current !== undefined) return cfg.current + if (mode === "image") return cfg.after ?? cfg.before + return cfg.after ?? cfg.before +} + +export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) { + const i18n = useI18n() + const cfg = () => props.media + const kind = createMemo(() => { + const media = cfg() + if (!media || media.mode === "off") return + return mediaKindFromPath(media.path) + }) + + const isBinary = createMemo(() => { + const media = cfg() + if (!media || media.mode === "off") return false + if (kind()) return false + return isBinaryContent(media.current as any) + }) + + const onLoad = () => props.media?.onLoad?.() + + const deleted = createMemo(() => { + const media = cfg() + const k = kind() + if (!media || !k) return false + if (media.deleted) return true + if (k === "svg") return false + if (media.current !== undefined) return false + return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any) + }) + + const direct = createMemo(() => { + const media = cfg() + const k = kind() + if (!media || (k !== "image" && k !== "audio")) return + return dataUrlFromMediaValue(mediaValue(media, k), k) + }) + + const request = createMemo(() => { + const media = cfg() + const k = kind() + if (!media || (k !== "image" && k !== "audio")) return + if (media.current !== undefined) return + if (deleted()) return + if (direct()) return + if (!media.path || !media.readFile) return + + return { + key: `${k}:${media.path}`, + kind: k, + path: media.path, + readFile: media.readFile, + onError: media.onError, + } + }) + + const [loaded] = createResource(request, async (input) => { + return input.readFile(input.path).then( + (result) => { + const src = dataUrlFromMediaValue(result as any, input.kind) + if (!src) { + input.onError?.({ kind: input.kind }) + return { key: input.key, error: true as const } + } + + return { + key: input.key, + src, + mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined, + } + }, + () => { + input.onError?.({ kind: input.kind }) + return { key: input.key, error: true as const } + }, + ) + }) + + const remote = createMemo(() => { + const input = request() + const value = loaded() + if (!input || !value || value.key !== input.key) return + return value + }) + + const src = createMemo(() => { + const value = remote() + return direct() ?? (value && "src" in value ? value.src : undefined) + }) + const status = createMemo(() => { + if (direct()) return "ready" as const + if (!request()) return "idle" as const + if (loaded.loading) return "loading" as const + if (remote()?.error) return "error" as const + if (src()) return "ready" as const + return "idle" as const + }) + const audioMime = createMemo(() => { + const value = remote() + return value && "mime" in value ? value.mime : undefined + }) + + const svgSource = createMemo(() => { + const media = cfg() + if (!media || kind() !== "svg") return + return svgTextFromValue(media.current as any) + }) + const svgSrc = createMemo(() => { + const media = cfg() + if (!media || kind() !== "svg") return + return dataUrlFromMediaValue(media.current as any, "svg") + }) + const svgInvalid = createMemo(() => { + const media = cfg() + if (!media || kind() !== "svg") return + if (svgSource() !== undefined) return + if (!hasMediaValue(media.current as any)) return + return [media.path, media.current] as const + }) + + createEffect( + on( + svgInvalid, + (value) => { + if (!value) return + cfg()?.onError?.({ kind: "svg" }) + }, + { defer: true }, + ), + ) + + const kindLabel = (value: "image" | "audio") => + i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio") + + return ( + + + { + const media = cfg() + const k = kind() + if (!media || (k !== "image" && k !== "audio")) return props.fallback() + const label = kindLabel(k) + + if (deleted()) { + return ( +
+ {i18n.t("ui.fileMedia.state.removed", { kind: label })} +
+ ) + } + if (status() === "loading") { + return ( +
+ {i18n.t("ui.fileMedia.state.loading", { kind: label })} +
+ ) + } + if (status() === "error") { + return ( +
+ {i18n.t("ui.fileMedia.state.error", { kind: label })} +
+ ) + } + return ( +
+ {i18n.t("ui.fileMedia.state.unavailable", { kind: label })} +
+ ) + })()} + > + {(value) => { + const k = kind() + if (k !== "image" && k !== "audio") return props.fallback() + if (k === "image") { + return ( +
+ {cfg()?.path} +
+ ) + } + + return ( +
+ +
+ ) + }} +
+
+ + {(() => { + if (svgSource() === undefined && svgSrc() == null) return props.fallback() + + return ( +
+ {props.fallback()} + + {(value) => ( +
+ {cfg()?.path} +
+ )} +
+
+ ) + })()} +
+ +
+
+ {cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")} +
+
+ {(() => { + const path = cfg()?.path + if (!path) return i18n.t("ui.fileMedia.binary.description.default") + return i18n.t("ui.fileMedia.binary.description.path", { path }) + })()} +
+
+
+ {props.fallback()} +
+ ) +} diff --git a/packages/session-ui/src/components/file-search.tsx b/packages/session-ui/src/components/file-search.tsx new file mode 100644 index 00000000000..181ff9fea8f --- /dev/null +++ b/packages/session-ui/src/components/file-search.tsx @@ -0,0 +1,72 @@ +import { Portal } from "solid-js/web" +import { useI18n } from "@opencode-ai/ui/context/i18n" +import { Icon } from "@opencode-ai/ui/icon" + +export function FileSearchBar(props: { + pos: () => { top: number; right: number } + query: () => string + index: () => number + count: () => number + setInput: (el: HTMLInputElement) => void + onInput: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onClose: () => void + onPrev: () => void + onNext: () => void +}) { + const i18n = useI18n() + + return ( + +
e.stopPropagation()} + > + + props.onInput(e.currentTarget.value)} + onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)} + /> +
+ {props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"} +
+
+ + +
+ +
+
+ ) +} diff --git a/packages/session-ui/src/components/file-ssr.tsx b/packages/session-ui/src/components/file-ssr.tsx new file mode 100644 index 00000000000..68b5568edc6 --- /dev/null +++ b/packages/session-ui/src/components/file-ssr.tsx @@ -0,0 +1,197 @@ +import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs" +import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" +import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" +import { Dynamic, isServer } from "solid-js/web" +import { useWorkerPool } from "@opencode-ai/ui/context/worker-pool" +import { createDefaultOptions, styleVariables } from "../pierre" +import { markCommentedDiffLines } from "../pierre/commented-lines" +import { fixDiffSelection } from "../pierre/diff-selection" +import { + applyViewerScheme, + clearReadyWatcher, + createReadyWatcher, + notifyShadowReady, + observeViewerScheme, +} from "../pierre/file-runtime" +import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" +import { File, type DiffFileProps, type FileProps } from "./file" + +type DiffPreload = PreloadMultiFileDiffResult | PreloadFileDiffResult + +type SSRDiffFileProps = DiffFileProps & { + preloadedDiff: DiffPreload +} + +function DiffSSRViewer(props: SSRDiffFileProps) { + let container!: HTMLDivElement + let fileDiffRef!: HTMLElement + let fileDiffInstance: FileDiff | undefined + let sharedVirtualizer: NonNullable> | undefined + + const ready = createReadyWatcher() + const workerPool = useWorkerPool(props.diffStyle) + + const [local, others] = splitProps(props, [ + "mode", + "media", + "fileDiff", + "before", + "after", + "class", + "classList", + "annotations", + "selectedLines", + "commentedLines", + "onLineSelected", + "onLineSelectionEnd", + "onLineNumberSelectionEnd", + "onRendered", + "preloadedDiff", + ]) + + const getRoot = () => fileDiffRef?.shadowRoot ?? undefined + + const getVirtualizer = () => { + if (sharedVirtualizer) return sharedVirtualizer.virtualizer + const result = acquireVirtualizer(container) + if (!result) return + sharedVirtualizer = result + return result.virtualizer + } + + const setSelectedLines = (range: DiffFileProps["selectedLines"], attempt = 0) => { + const diff = fileDiffInstance + if (!diff) return + + const fixed = fixDiffSelection(getRoot(), range ?? null) + if (fixed === undefined) { + if (attempt >= 120) return + requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1)) + return + } + + diff.setSelectedLines(fixed) + } + + const notifyRendered = () => { + notifyShadowReady({ + state: ready, + container, + getRoot, + isReady: (root) => root.querySelector("[data-line]") != null, + settleFrames: 1, + onReady: () => { + setSelectedLines(local.selectedLines ?? null) + local.onRendered?.() + }, + }) + } + + onMount(() => { + if (isServer) return + + onCleanup(observeViewerScheme(() => fileDiffRef)) + + const virtualizer = getVirtualizer() + const annotations = local.annotations ?? local.preloadedDiff.annotations ?? [] + fileDiffInstance = virtualizer + ? new VirtualizedFileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...local.preloadedDiff.options, + }, + virtualizer, + virtualMetrics, + workerPool, + ) + : new FileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...local.preloadedDiff.options, + }, + workerPool, + ) + + applyViewerScheme(fileDiffRef) + + // @ts-expect-error private field required for hydration + fileDiffInstance.fileContainer = fileDiffRef + fileDiffInstance.hydrate( + local.fileDiff + ? { + fileDiff: local.fileDiff, + lineAnnotations: annotations, + fileContainer: fileDiffRef, + containerWrapper: container, + prerenderedHTML: local.preloadedDiff.prerenderedHTML, + } + : { + oldFile: local.before + ? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" } + : local.before, + newFile: local.after + ? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" } + : local.after, + lineAnnotations: annotations, + fileContainer: fileDiffRef, + containerWrapper: container, + prerenderedHTML: local.preloadedDiff.prerenderedHTML, + }, + ) + + notifyRendered() + }) + + createEffect(() => { + const diff = fileDiffInstance + if (!diff) return + diff.setLineAnnotations(local.annotations ?? []) + diff.rerender() + }) + + createEffect(() => { + setSelectedLines(local.selectedLines ?? null) + }) + + createEffect(() => { + const ranges = local.commentedLines ?? [] + requestAnimationFrame(() => { + const root = getRoot() + if (!root) return + markCommentedDiffLines(root, ranges) + }) + }) + + onCleanup(() => { + clearReadyWatcher(ready) + fileDiffInstance?.cleanUp() + sharedVirtualizer?.release() + sharedVirtualizer = undefined + }) + + return ( +
+ + +