mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
chore: merge main into developing-liver
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support adaptive thinking levels for Claude Opus and Sonnet 5 and later.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix settings changes sometimes failing to save and apply in VS Code.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve parenthesized tilde expressions as literal text in rendered chat messages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Emit each agent event once from `kilo run --format json`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Stabilize cross-platform CLI subprocess tests under constrained CI runners
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints
|
||||
@@ -10,7 +10,7 @@
|
||||
* warning — its PRs show up in the rolling PR body as skipped, so nothing
|
||||
* fails silently.
|
||||
*
|
||||
* Env: EDIT_MODEL (provider/model), KILO_API_KEY (set by workflow; read natively by the kilo provider).
|
||||
* Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider).
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* "unclassified" entries (docs_worthy=false) instead of failing the run —
|
||||
* the PR body then shows those PRs as skipped, visible to reviewers.
|
||||
*
|
||||
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY (gateway auth, set by the workflow;
|
||||
* the kilo provider reads it natively). Reads the prompt from triage-prompt.md next to this script.
|
||||
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by
|
||||
* the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
|
||||
@@ -41,6 +41,11 @@ jobs:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
# Both are required: without KILO_ORG_ID the gateway bills the key
|
||||
# owner's personal balance (402 "Add credits") instead of the org.
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -74,8 +79,6 @@ jobs:
|
||||
- name: Triage merged PRs (LLM, chunked)
|
||||
id: triage
|
||||
if: steps.collect.outputs.count != '0'
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
run: node .github/docs-sync/triage.mjs
|
||||
|
||||
- name: Filter docs-worthy PRs
|
||||
@@ -103,8 +106,6 @@ jobs:
|
||||
|
||||
- name: Update docs (Kilo CLI, batched)
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
run: node .github/docs-sync/edit.mjs
|
||||
|
||||
- name: Verify docs build and tests
|
||||
@@ -122,7 +123,6 @@ jobs:
|
||||
if: steps.verify.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
# kilocode_change - new file
|
||||
name: publish-jetbrains-bundled
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: Merged JetBrains release PR number to bundle
|
||||
required: true
|
||||
type: string
|
||||
merge_commit:
|
||||
description: Merge commit SHA from the reviewed release PR
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: publish-jetbrains-bundled-pr-${{ inputs.pr }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
kind: ${{ steps.release.outputs.kind }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
channel: ${{ steps.release.outputs.marketplace_channel }}
|
||||
steps:
|
||||
- name: Checkout trusted validation scripts
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Setup Bun for validation
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Checkout merged release PR for validation
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: release
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.merge_commit }}
|
||||
|
||||
- name: Validate release PR and tag
|
||||
id: release
|
||||
working-directory: release
|
||||
run: bun ../script/jetbrains-release-validate.ts --pr "$PR_NUMBER"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ inputs.pr }}
|
||||
|
||||
bundle:
|
||||
needs: validate
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ needs.validate.outputs.version }}
|
||||
kind: ${{ needs.validate.outputs.kind }}
|
||||
steps:
|
||||
- name: Checkout merged release PR metadata
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.merge_commit }}
|
||||
|
||||
- name: Save reviewed release metadata
|
||||
run: |
|
||||
cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md"
|
||||
cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Restore reviewed release metadata
|
||||
run: |
|
||||
cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md
|
||||
cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y patchelf zip unzip
|
||||
curl --fail --location \
|
||||
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
|
||||
--output "$RUNNER_TEMP/zig.tar.xz"
|
||||
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
|
||||
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Validate signing secrets
|
||||
run: |
|
||||
missing=0
|
||||
for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
|
||||
if [[ -z "${!name}" ]]; then
|
||||
echo "Missing required secret: $name" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
exit "$missing"
|
||||
env:
|
||||
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Build signed bundled plugin
|
||||
working-directory: packages/kilo-jetbrains
|
||||
run: |
|
||||
args=(
|
||||
-Pproduction=true
|
||||
-Pkilo.version="$VERSION"
|
||||
-Pkilo.channel="$CHANNEL"
|
||||
-Pkilo.cli.bundled=true
|
||||
)
|
||||
|
||||
./gradlew clean buildPlugin "${args[@]}"
|
||||
./gradlew signPlugin "${args[@]}"
|
||||
./gradlew verifyPluginSignature "${args[@]}"
|
||||
./gradlew verifyPlugin "${args[@]}"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Resolve bundled archive
|
||||
id: archive
|
||||
run: |
|
||||
mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
|
||||
if [[ "${#signed[@]}" -ne 1 ]]; then
|
||||
echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2
|
||||
printf '%s\n' "${signed[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
asset="kilo-code-${VERSION}-bundled.zip"
|
||||
dest="packages/kilo-jetbrains/build/release/$asset"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp "${signed[0]}" "$dest"
|
||||
echo "asset=$asset" >> "$GITHUB_OUTPUT"
|
||||
echo "path=$dest" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
|
||||
- name: Upload bundled ZIP to GitHub Release
|
||||
run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
ARCHIVE: ${{ steps.archive.outputs.path }}
|
||||
|
||||
- name: Resolve bundled asset URL
|
||||
id: asset
|
||||
run: |
|
||||
url="$(gh release view "$TAG" --json assets --jq '.assets[] | select(.name == env.ASSET) | .url' --repo "$GITHUB_REPOSITORY")"
|
||||
if [[ -z "$url" ]]; then
|
||||
echo "Could not resolve GitHub Release URL for $ASSET" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
ASSET: ${{ steps.archive.outputs.asset }}
|
||||
|
||||
- name: Generate stable plugin repository XML
|
||||
if: needs.validate.outputs.kind == 'stable'
|
||||
run: |
|
||||
mkdir -p pages/jetbrains
|
||||
python3 <<'PY'
|
||||
import html
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
archive = os.environ["ARCHIVE"]
|
||||
asset = os.environ["ASSET_URL"]
|
||||
version = os.environ["VERSION"]
|
||||
|
||||
def plugin_xml(path):
|
||||
with zipfile.ZipFile(path) as zip:
|
||||
for name in zip.namelist():
|
||||
if name.endswith("META-INF/plugin.xml"):
|
||||
return zip.read(name)
|
||||
for name in zip.namelist():
|
||||
if not name.endswith(".jar"):
|
||||
continue
|
||||
with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar:
|
||||
for item in jar.namelist():
|
||||
if item.endswith("META-INF/plugin.xml"):
|
||||
return jar.read(item)
|
||||
raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml")
|
||||
|
||||
root = ET.fromstring(plugin_xml(archive))
|
||||
|
||||
def text(name, default=""):
|
||||
item = root.find(name)
|
||||
return item.text.strip() if item is not None and item.text else default
|
||||
|
||||
def cdata(value):
|
||||
return "<![CDATA[" + value.replace("]]>", "]]]]><![CDATA[>") + "]]>"
|
||||
|
||||
plugin = text("id", "ai.kilocode.jetbrains")
|
||||
name = text("name", "Kilo Code")
|
||||
vendor = text("vendor", "Kilo Code")
|
||||
desc = text("description")
|
||||
notes = text("change-notes")
|
||||
idea = root.find("idea-version")
|
||||
attrs = ""
|
||||
if idea is not None:
|
||||
since = idea.attrib.get("since-build")
|
||||
until = idea.attrib.get("until-build")
|
||||
if since:
|
||||
attrs += f' since-build="{html.escape(since)}"'
|
||||
if until:
|
||||
attrs += f' until-build="{html.escape(until)}"'
|
||||
|
||||
xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plugins>',
|
||||
f' <plugin id="{html.escape(plugin)}" version="{html.escape(version)}" url="{html.escape(asset)}">',
|
||||
f' <name>{html.escape(name)}</name>',
|
||||
f' <vendor>{html.escape(vendor)}</vendor>',
|
||||
f' <idea-version{attrs}/>',
|
||||
]
|
||||
if desc:
|
||||
xml.append(f' <description>{cdata(desc)}</description>')
|
||||
if notes:
|
||||
xml.append(f' <change-notes>{cdata(notes)}</change-notes>')
|
||||
xml.extend([' </plugin>', '</plugins>', ''])
|
||||
with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file:
|
||||
file.write("\n".join(xml))
|
||||
PY
|
||||
env:
|
||||
ARCHIVE: ${{ steps.archive.outputs.path }}
|
||||
ASSET_URL: ${{ steps.asset.outputs.url }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
|
||||
- name: Upload stable Pages source
|
||||
if: needs.validate.outputs.kind == 'stable'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jetbrains-pages-${{ needs.validate.outputs.version }}
|
||||
path: pages
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: kilo-jetbrains-bundled-${{ needs.validate.outputs.version }}
|
||||
path: |
|
||||
packages/kilo-jetbrains/build/release/*.zip
|
||||
pages/jetbrains/updatePlugins.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
pages:
|
||||
needs: bundle
|
||||
if: needs.bundle.outputs.kind == 'stable'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
pages: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Download stable Pages source
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: jetbrains-pages-${{ needs.bundle.outputs.version }}
|
||||
path: pages
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: pages
|
||||
|
||||
- name: Deploy Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -23,6 +23,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
@@ -199,6 +200,19 @@ jobs:
|
||||
ARCHIVE: ${{ steps.archive.outputs.path }}
|
||||
NOTES: packages/kilo-jetbrains/build/release-notes.md
|
||||
|
||||
- name: Dispatch bundled GitHub release build
|
||||
continue-on-error: true
|
||||
run: |
|
||||
gh workflow run publish-jetbrains-bundled.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
-f pr="$PR_NUMBER" \
|
||||
-f merge_commit="$MERGE_COMMIT"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
|
||||
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }}
|
||||
|
||||
- name: Upload workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -157,6 +157,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
|
||||
|
||||
- CLI process spawning, download, extraction, and lifecycle belong in `backend`.
|
||||
- By default, the plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` and `cli.pinned` for split-mode RPC and runtime use.
|
||||
- Bundled release builds pass `-Pkilo.cli.bundled=true` while keeping `kilo.cli.pinned=true`. This build-only flag stages all pinned CLI release assets into `kilo-cli.zip`; runtime detects that resource and extracts only the current platform instead of downloading. Do not add a `cli.bundled` key to `kilo.properties` or repurpose `kilo.cli.pinned=false` for public bundled releases.
|
||||
- For release questions, use the `release-jetbrains` skill and reference `.kilo/skills/release-jetbrains/SKILL.md`; it verifies the CLI pin before creating immutable `jetbrains/v*` tags.
|
||||
- For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`.
|
||||
- Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`.
|
||||
|
||||
@@ -102,6 +102,23 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [7.0.11] - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
- Add a signed GitHub-hosted bundled JetBrains plugin build that includes the Kilo CLI for offline or restricted-network installs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Load global skills reliably from JetBrains projects that are not inside a Git repository.
|
||||
- Support adaptive thinking for Claude Opus and Sonnet 5+ model identifiers across Anthropic, AI Gateway, and Bedrock providers.
|
||||
- Flush pending cloud session updates when the Kilo Core runtime shuts down, reducing cases where the final assistant message is missing when a session is reopened elsewhere.
|
||||
- Prune stale bundled CLI versions after upgrading bundled JetBrains installs.
|
||||
|
||||
### Changed
|
||||
|
||||
- Update the JetBrains CLI pin from Kilo Core 7.4.15 to 7.4.16.
|
||||
## [7.0.10] - 2026-07-24
|
||||
## [7.0.10] - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -78,7 +78,7 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip
|
||||
|
||||
## Releasing
|
||||
|
||||
See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, and how to install RC builds via the custom plugin repository.
|
||||
See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, how to install RC builds, and how the signed bundled CLI build is published to the GitHub-hosted stable plugin repository.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
- Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`.
|
||||
- Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment.
|
||||
- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases for `jetbrains/v*` tags.
|
||||
- Confirm `GITHUB_TOKEN` has `actions: write`, `pages: write`, and `id-token: write` permission for dispatching bundled releases and publishing the stable GitHub Pages plugin repository.
|
||||
- Configure GitHub Pages for this repository with source set to GitHub Actions.
|
||||
- Confirm `KILO_MAINTAINER_APP_ID` and `KILO_MAINTAINER_APP_SECRET` are available to create release PRs and immediate release tags.
|
||||
- Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers.
|
||||
- If using an environment, move the Marketplace and signing secrets there and set the workflow job environment.
|
||||
@@ -35,6 +37,7 @@
|
||||
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
|
||||
- Merge the release PR to trigger publish from `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`.
|
||||
- Watch the `publish-jetbrains` workflow.
|
||||
- Confirm the follow-up `publish-jetbrains-bundled` workflow completes and attaches `kilo-code-x.y.z-rc.n-bundled.zip` to the prerelease.
|
||||
- Download and retain the workflow artifact if needed.
|
||||
- Confirm the update appears on the JetBrains Marketplace `eap` channel.
|
||||
- Confirm the GitHub Release for the `jetbrains/vx.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset.
|
||||
@@ -48,5 +51,6 @@
|
||||
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
|
||||
- Merge the release PR to trigger publish from `jetbrains/vx.y.z`.
|
||||
- Watch the `publish-jetbrains` workflow.
|
||||
- Confirm the follow-up `publish-jetbrains-bundled` workflow completes, attaches `kilo-code-x.y.z-bundled.zip`, and updates `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
|
||||
- Confirm the update appears on the default JetBrains Marketplace channel.
|
||||
- Confirm the GitHub Release for the `jetbrains/vx.y.z` tag exists and contains the JetBrains plugin ZIP asset.
|
||||
|
||||
@@ -132,6 +132,16 @@ Publishing behavior:
|
||||
|
||||
The workflow checks out `jetbrains/v<version>` for verification, signing, and Marketplace publishing. It overlays the reviewed `packages/kilo-jetbrains/gradle.properties` and `packages/kilo-jetbrains/CHANGELOG.md` from the merged PR before rendering release notes and before `publishPlugin`, so the Marketplace plugin version, Marketplace notes, and GitHub Release use the reviewed metadata.
|
||||
|
||||
After Marketplace publishing succeeds, `publish-jetbrains` dispatches `publish-jetbrains-bundled`. The bundled workflow rebuilds the same `jetbrains/v<version>` tag with `-Pkilo.cli.bundled=true`, signs and verifies the all-platform plugin ZIP, then uploads `kilo-code-<version>-bundled.zip` to the same GitHub Release. Bundled builds keep `kilo.cli.pinned=true`; the build flag only embeds the pinned CLI release assets so runtime extracts the bundled current-platform CLI instead of downloading it.
|
||||
|
||||
Stable bundled releases also publish the GitHub Pages custom plugin repository XML:
|
||||
|
||||
```text
|
||||
https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml
|
||||
```
|
||||
|
||||
RC bundled ZIPs are attached to prereleases for install-from-disk testing, but they do not update the stable custom repository XML.
|
||||
|
||||
## Installing RC Builds
|
||||
|
||||
RC builds are published to the `eap` channel. To get them in IntelliJ IDEA:
|
||||
|
||||
@@ -22,6 +22,7 @@ val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
|
||||
val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res")
|
||||
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true)
|
||||
val repoCli = pinned.map { !it }
|
||||
val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false)
|
||||
val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
|
||||
|
||||
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
|
||||
@@ -32,11 +33,15 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector
|
||||
sourceSets {
|
||||
main {
|
||||
resources.srcDir(generatedProps)
|
||||
if (repoCli.get()) resources.srcDir(generatedCli)
|
||||
if (repoCli.get() || bundled.get()) resources.srcDir(generatedCli)
|
||||
kotlin.srcDir(generatedApi)
|
||||
}
|
||||
}
|
||||
|
||||
if (repoCli.get() && bundled.get()) {
|
||||
error("kilo.cli.bundled=true requires kilo.cli.pinned=true; do not combine release CLI bundling with local repo CLI mode.")
|
||||
}
|
||||
|
||||
val writeKiloProperties by tasks.registering(WriteProperties::class) {
|
||||
description = "Write pinned Kilo CLI properties"
|
||||
val out = generatedProps.map { it.file("kilo.properties") }
|
||||
@@ -88,6 +93,17 @@ val stageRepoCli by tasks.registering(StageRepoCliTask::class) {
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
val stageBundledCli by tasks.registering(StageBundledCliTask::class) {
|
||||
description = "Stage all pinned Kilo CLI release assets into backend resources"
|
||||
cliVersion.set(pinnedCliVersion)
|
||||
token.set(
|
||||
providers.environmentVariable("GH_TOKEN")
|
||||
.orElse(providers.environmentVariable("GITHUB_TOKEN"))
|
||||
)
|
||||
cacheDir.set(layout.buildDirectory.dir("cli-cache"))
|
||||
archive.set(generatedCli.map { it.file("kilo-cli.zip") })
|
||||
}
|
||||
|
||||
val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) {
|
||||
description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation"
|
||||
dependsOn(generateOpenApiSpec)
|
||||
@@ -143,12 +159,14 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
|
||||
tasks.named("compileKotlin") {
|
||||
dependsOn(fixGeneratedApi, writeKiloProperties)
|
||||
if (repoCli.get()) dependsOn(stageRepoCli)
|
||||
if (bundled.get()) dependsOn(stageBundledCli)
|
||||
inputs.dir(generatedApi)
|
||||
}
|
||||
|
||||
tasks.named("processResources") {
|
||||
dependsOn(writeKiloProperties)
|
||||
if (repoCli.get()) dependsOn(stageRepoCli)
|
||||
if (bundled.get()) dependsOn(stageBundledCli)
|
||||
}
|
||||
|
||||
tasks.named("compileTestKotlin") {
|
||||
|
||||
+2
-2
@@ -126,8 +126,8 @@ class KiloBackendCliManager(
|
||||
private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File {
|
||||
val force = forceExtract
|
||||
forceExtract = false
|
||||
if (!KiloProps.pinned()) {
|
||||
if (force) log.info("Force re-extracting local repo CLI ${KiloProps.cliVersion()}")
|
||||
if (KiloRepoCli.available()) {
|
||||
if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}")
|
||||
val cli = KiloRepoCli.extract(force)
|
||||
onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current()))
|
||||
return cli
|
||||
|
||||
+57
-9
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.application.PathManager
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -10,20 +11,33 @@ import java.io.OutputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
object KiloRepoCli {
|
||||
private const val ARCHIVE = "kilo-cli.zip"
|
||||
private val log = KiloLog.create(KiloRepoCli::class.java)
|
||||
|
||||
fun available(): Boolean = KiloRepoCli::class.java.classLoader.getResource(ARCHIVE) != null
|
||||
|
||||
suspend fun extract(force: Boolean): File = extract(
|
||||
force = force,
|
||||
root = File(PathManager.getSystemPath(), "kilo/repo-cli"),
|
||||
root = File(PathManager.getSystemPath(), "kilo/repo-cli/${KiloProps.cliVersion()}"),
|
||||
cleanup = true,
|
||||
source = {
|
||||
KiloRepoCli::class.java.classLoader.getResourceAsStream("kilo-cli.zip")
|
||||
?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with kilo.cli.pinned=false")
|
||||
KiloRepoCli::class.java.classLoader.getResourceAsStream(ARCHIVE)
|
||||
?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with bundled CLI resources")
|
||||
},
|
||||
)
|
||||
|
||||
internal suspend fun extract(force: Boolean, root: File, source: () -> InputStream): File = withContext(Dispatchers.IO) {
|
||||
val exe = File(root, "bin/${KiloCliPlatform.exe()}")
|
||||
internal suspend fun extract(
|
||||
force: Boolean,
|
||||
root: File,
|
||||
cleanup: Boolean = false,
|
||||
source: () -> InputStream,
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val platform = KiloCliPlatform.current()
|
||||
val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}")
|
||||
val done = File(root, ".complete")
|
||||
if (!force && done.isFile && exe.isFile) {
|
||||
if (!SystemInfo.isWindows) exe.setExecutable(true)
|
||||
if (cleanup) prune(root)
|
||||
return@withContext exe
|
||||
}
|
||||
|
||||
@@ -38,21 +52,55 @@ object KiloRepoCli {
|
||||
ZipInputStream(input.buffered()).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
write(root, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
|
||||
val path = select(root, entry.name, platform)
|
||||
if (path != null) write(root, path, entry.isDirectory) { out -> zip.copyTo(out) }
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!exe.isFile) throw IllegalStateException("Local repo CLI archive did not contain bin/${KiloCliPlatform.exe()}")
|
||||
if (!exe.isFile) throw IllegalStateException("Bundled CLI archive did not contain $platform/bin/${KiloCliPlatform.exe()}")
|
||||
if (!SystemInfo.isWindows) exe.setExecutable(true)
|
||||
done.writeText("ok\n")
|
||||
if (cleanup) prune(root)
|
||||
return@withContext exe
|
||||
}
|
||||
|
||||
private fun prune(root: File) {
|
||||
val parent = root.parentFile ?: return
|
||||
val entries = parent.listFiles() ?: return
|
||||
for (entry in entries) {
|
||||
if (!entry.isDirectory || entry.name == root.name || entry.name.startsWith(".")) continue
|
||||
log.info("Removing stale bundled Kilo CLI version ${entry.absolutePath}")
|
||||
if (!entry.deleteRecursively()) {
|
||||
log.warn("Failed to remove stale bundled Kilo CLI version ${entry.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun select(dir: File, name: String, platform: String): String? {
|
||||
check(dir, name)
|
||||
val path = name.replace('\\', '/')
|
||||
val prefix = "$platform/"
|
||||
if (path.startsWith(prefix)) return path
|
||||
if (path.startsWith("bin/")) return "$platform/$path"
|
||||
return null
|
||||
}
|
||||
|
||||
private fun check(dir: File, name: String) {
|
||||
val raw = name.replace('\\', '/')
|
||||
if (raw.startsWith("/")) throw IllegalStateException("Archive entry escapes target directory: $name")
|
||||
val parts = raw.split('/').filter { it.isNotEmpty() }
|
||||
if (parts.any { it == ".." }) throw IllegalStateException("Archive entry escapes target directory: $name")
|
||||
val target = File(dir, name).canonicalFile
|
||||
val base = dir.canonicalFile
|
||||
if (target != base && !target.path.startsWith(base.path + File.separator)) {
|
||||
throw IllegalStateException("Archive entry escapes target directory: $name")
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(dir: File, name: String, directory: Boolean, copy: (OutputStream) -> Unit) {
|
||||
val path = if (name.startsWith("bin/")) name else "bin/$name"
|
||||
val target = File(dir, path).canonicalFile
|
||||
val target = File(dir, name).canonicalFile
|
||||
val base = dir.canonicalFile
|
||||
if (target != base && !target.path.startsWith(base.path + File.separator)) {
|
||||
throw IllegalStateException("Archive entry escapes target directory: $name")
|
||||
|
||||
+46
@@ -12,6 +12,7 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KiloRepoCliTest {
|
||||
@@ -38,6 +39,38 @@ class KiloRepoCliTest {
|
||||
assertEquals("#!/bin/new\n", forced.readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extracts only current platform from multi platform archive`() = runBlocking {
|
||||
val platform = KiloCliPlatform.current()
|
||||
val other = if (platform == "windows-x64") "darwin-arm64" else "windows-x64"
|
||||
val cli = KiloRepoCli.extract(false, dir) {
|
||||
ByteArrayInputStream(multi(platform, other))
|
||||
}
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertEquals("current", cli.readText())
|
||||
assertFalse(File(dir, "$other/bin/kilo.exe").exists())
|
||||
assertFalse(File(dir, "$other/bin/kilo").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prunes stale bundled cli versions after resolve`() = runBlocking {
|
||||
val root = File(dir, "7.4.11")
|
||||
val stale = File(dir, "7.4.10")
|
||||
File(stale, "old").apply {
|
||||
parentFile.mkdirs()
|
||||
writeText("old")
|
||||
}
|
||||
|
||||
val cli = KiloRepoCli.extract(false, root, cleanup = true) {
|
||||
ByteArrayInputStream(archive("current"))
|
||||
}
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertFalse(stale.exists())
|
||||
assertTrue(root.isDirectory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects archive entries that escape root`() = runBlocking {
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
@@ -66,4 +99,17 @@ class KiloRepoCliTest {
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun multi(platform: String, other: String): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
ZipOutputStream(out).use { zip ->
|
||||
zip.putNextEntry(ZipEntry("$platform/bin/${KiloCliPlatform.exe()}"))
|
||||
zip.write("current".toByteArray())
|
||||
zip.closeEntry()
|
||||
zip.putNextEntry(ZipEntry("$other/bin/kilo.exe"))
|
||||
zip.write("other".toByteArray())
|
||||
zip.closeEntry()
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.Internal
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
abstract class StageBundledCliTask : DefaultTask() {
|
||||
companion object {
|
||||
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags"
|
||||
private val PLATFORMS = listOf(
|
||||
"darwin-arm64",
|
||||
"darwin-x64",
|
||||
"linux-arm64",
|
||||
"linux-x64",
|
||||
"windows-arm64",
|
||||
"windows-x64",
|
||||
)
|
||||
}
|
||||
|
||||
@get:Input
|
||||
abstract val cliVersion: Property<String>
|
||||
|
||||
@get:Internal
|
||||
abstract val token: Property<String>
|
||||
|
||||
@get:Internal
|
||||
abstract val cacheDir: DirectoryProperty
|
||||
|
||||
@get:OutputFile
|
||||
abstract val archive: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val ver = cliVersion.get()
|
||||
val assets = assets(ver)
|
||||
val files = PLATFORMS.associateWith { platform ->
|
||||
val ext = ext(platform)
|
||||
val name = "kilo-$platform.$ext"
|
||||
val digest = assets[name] ?: throw GradleException("Kilo CLI release $ver did not include $name")
|
||||
val file = cacheDir.dir(ver).map { it.dir(platform).file(name) }.get().asFile
|
||||
fetch(ver, platform, name, digest, file)
|
||||
file
|
||||
}
|
||||
|
||||
val out = archive.get().asFile
|
||||
out.parentFile.mkdirs()
|
||||
ZipOutputStream(out.outputStream().buffered()).use { zip ->
|
||||
for ((platform, file) in files) {
|
||||
if (file.name.endsWith(".zip")) {
|
||||
zip(platform, file, zip)
|
||||
continue
|
||||
}
|
||||
tar(platform, file, zip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun assets(ver: String): Map<String, String> {
|
||||
val url = "$API/v$ver"
|
||||
logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url")
|
||||
val conn = connect(url)
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) fail(conn, code, "Failed to fetch pinned Kilo CLI release metadata")
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
return JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
|
||||
?.associate { item ->
|
||||
val obj = item.jsonObject
|
||||
val name = obj["name"]?.jsonPrimitive?.contentOrNull
|
||||
val digest = obj["digest"]?.jsonPrimitive?.contentOrNull
|
||||
if (name.isNullOrBlank() || digest.isNullOrBlank()) return@associate "" to ""
|
||||
name to digest
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?.mapValues { item ->
|
||||
val digest = item.value
|
||||
if (!digest.matches(DIGEST)) {
|
||||
throw GradleException("Pinned Kilo CLI release $ver asset ${item.key} has invalid digest")
|
||||
}
|
||||
digest
|
||||
}
|
||||
?: emptyMap()
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetch(ver: String, platform: String, name: String, digest: String, file: File) {
|
||||
if (file.isFile && sum(file) == digest) return
|
||||
file.parentFile.mkdirs()
|
||||
val url = "https://github.com/Kilo-Org/kilocode/releases/download/v$ver/$name"
|
||||
logger.lifecycle("Downloading pinned Kilo CLI $platform from $url")
|
||||
val conn = connect(url)
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) fail(conn, code, "Failed to download pinned Kilo CLI $platform")
|
||||
conn.inputStream.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
verify(file, digest)
|
||||
}
|
||||
|
||||
private fun zip(platform: String, file: File, out: ZipOutputStream) {
|
||||
ZipInputStream(file.inputStream().buffered()).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
if (!entry.isDirectory) write(out, platform, entry.name) { zip.copyTo(out) }
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tar(platform: String, file: File, out: ZipOutputStream) {
|
||||
TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar ->
|
||||
while (true) {
|
||||
val entry = tar.nextEntry ?: break
|
||||
if (entry.isDirectory) continue
|
||||
if (entry.isSymbolicLink || !entry.isFile) {
|
||||
throw GradleException("Unsupported CLI tar entry type in ${file.name}: ${entry.name}")
|
||||
}
|
||||
write(out, platform, entry.name) { tar.copyTo(out) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(out: ZipOutputStream, platform: String, name: String, copy: () -> Unit) {
|
||||
out.putNextEntry(ZipEntry(path(platform, name)))
|
||||
copy()
|
||||
out.closeEntry()
|
||||
}
|
||||
|
||||
private fun path(platform: String, name: String): String {
|
||||
val raw = name.replace('\\', '/')
|
||||
if (raw.startsWith("/")) throw GradleException("Archive entry escapes target directory: $name")
|
||||
val parts = raw.split('/').filter { it.isNotEmpty() && it != "." }
|
||||
if (parts.isEmpty()) throw GradleException("Archive entry is empty: $name")
|
||||
if (parts.any { it == ".." }) throw GradleException("Archive entry escapes target directory: $name")
|
||||
val path = if (parts.first() == "bin") parts else listOf("bin") + parts
|
||||
return "$platform/${path.joinToString("/")}"
|
||||
}
|
||||
|
||||
private fun verify(file: File, digest: String) {
|
||||
val actual = sum(file)
|
||||
if (actual == digest) return
|
||||
if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}")
|
||||
throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
|
||||
}
|
||||
|
||||
private fun sum(file: File) = "sha256:${sha256(file)}"
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
md.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
private fun connect(url: String): HttpURLConnection {
|
||||
val conn = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 120_000
|
||||
conn.instanceFollowRedirects = true
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
token.getOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
|
||||
return conn
|
||||
}
|
||||
|
||||
private fun fail(conn: HttpURLConnection, code: Int, msg: String): Nothing {
|
||||
val info = rate(conn)
|
||||
val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }
|
||||
.getOrNull()
|
||||
?.take(500)
|
||||
val detail = if (body.isNullOrBlank()) "" else ": $body"
|
||||
if (limited(conn, code)) {
|
||||
throw GradleException("GitHub API rate limit exceeded while staging bundled Kilo CLI ($info)$detail")
|
||||
}
|
||||
throw GradleException("$msg: HTTP $code from ${conn.url} ($info)$detail")
|
||||
}
|
||||
|
||||
private fun rate(conn: HttpURLConnection): String {
|
||||
val reset = conn.getHeaderField("X-RateLimit-Reset")
|
||||
?.toLongOrNull()
|
||||
?.let { Instant.ofEpochSecond(it).toString() }
|
||||
return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " +
|
||||
"used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}"
|
||||
}
|
||||
|
||||
private fun limited(conn: HttpURLConnection, code: Int) =
|
||||
code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0")
|
||||
|
||||
private fun ext(platform: String) = if (platform.startsWith("linux-")) "tar.gz" else "zip"
|
||||
}
|
||||
@@ -28,12 +28,13 @@ abstract class StageRepoCliTask : DefaultTask() {
|
||||
}
|
||||
|
||||
val out = archive.get().asFile
|
||||
val platform = platform()
|
||||
out.parentFile.mkdirs()
|
||||
ZipOutputStream(out.outputStream().buffered()).use { zip ->
|
||||
dir.walkTopDown()
|
||||
.filter { it.isFile }
|
||||
.forEach { file ->
|
||||
val name = "bin/${file.relativeTo(dir).invariantSeparatorsPath}"
|
||||
val name = "$platform/bin/${file.relativeTo(dir).invariantSeparatorsPath}"
|
||||
zip.putNextEntry(ZipEntry(name))
|
||||
file.inputStream().use { it.copyTo(zip) }
|
||||
zip.closeEntry()
|
||||
@@ -42,4 +43,20 @@ abstract class StageRepoCliTask : DefaultTask() {
|
||||
}
|
||||
|
||||
private fun exe() = if (System.getProperty("os.name").lowercase().contains("windows")) "kilo.exe" else "kilo"
|
||||
|
||||
private fun platform(): String {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
val name = when {
|
||||
os.contains("mac") || os.contains("darwin") -> "darwin"
|
||||
os.contains("linux") -> "linux"
|
||||
os.contains("windows") -> "windows"
|
||||
else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}")
|
||||
}
|
||||
val arch = when (System.getProperty("os.arch").lowercase()) {
|
||||
"aarch64", "arm64" -> "arm64"
|
||||
"x86_64", "amd64" -> "x64"
|
||||
else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}")
|
||||
}
|
||||
return "$name-$arch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# JetBrains Bundled-CLI Release Plan
|
||||
|
||||
Ship a signed, all-platform, CLI-bundled build of the Kilo JetBrains plugin to a GitHub-hosted custom plugin repository, as an alternative to the JetBrains Marketplace, which caps plugin ZIPs at 400 MB. The Marketplace build stays lean and downloads the CLI at runtime; the bundled build embeds every platform's CLI so it works offline or on restricted networks.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Host `updatePlugins.xml` via GitHub Pages deployed by Actions.
|
||||
2. Maintain a single stable custom repo: one `updatePlugins.xml`, updated on stable releases only.
|
||||
3. Auto-trigger the bundled workflow after `publish-jetbrains` succeeds.
|
||||
4. Decide runtime delivery by presence of the bundled `kilo-cli.zip` resource. Do not add a `kilo.properties` flag, and do not edit committed files for a bundled build.
|
||||
|
||||
## Core Principle
|
||||
|
||||
- A bundled build uses the same `jetbrains/v<version>` tag, the same source, and `kilo.cli.pinned=true`.
|
||||
- The only build difference is the override `-Pkilo.cli.bundled=true`.
|
||||
- `kilo.properties` stays byte-identical between Marketplace and bundled builds. The only build-output difference is whether `kilo-cli.zip` is embedded in the backend jar.
|
||||
- `kilo.cli.pinned` keeps its existing meaning: which CLI version / OpenAPI source / release guard. It does not control runtime delivery.
|
||||
|
||||
## Phase 1: Backend Delivery
|
||||
|
||||
- Add `KiloRepoCli.available()` to detect `kilo-cli.zip` on the classpath.
|
||||
- Change `KiloBackendCliManager.resolveCli()` to extract when `KiloRepoCli.available()` is true; otherwise download the pinned release asset.
|
||||
- Store bundled archives as `<platform>/bin/kilo[.exe]` for all six platforms: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `windows-arm64`, `windows-x64`.
|
||||
- Extract only the current platform's subtree to disk so users do not store all six binaries locally.
|
||||
- Keep path traversal checks for every archive entry.
|
||||
- Update repo CLI dev staging to use the same layout.
|
||||
|
||||
## Phase 2: Gradle Bundling
|
||||
|
||||
- Add a build-only property `kilo.cli.bundled`, defaulting to false.
|
||||
- Keep `kilo.cli.pinned=true` for bundled production builds.
|
||||
- Add a task that downloads all six pinned CLI release assets from GitHub, verifies their `sha256` digests from release metadata, and assembles `kilo-cli.zip` as a backend resource.
|
||||
- Wire that generated resource only when `-Pkilo.cli.bundled=true` or local repo CLI mode is active.
|
||||
- Leave the production guard against `kilo.cli.pinned=false` intact.
|
||||
|
||||
Bundled build command:
|
||||
|
||||
```bash
|
||||
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \
|
||||
-Pproduction=true -Pkilo.version=<version> -Pkilo.channel=default \
|
||||
-Pkilo.cli.bundled=true
|
||||
```
|
||||
|
||||
## Phase 3: Bundle Workflow
|
||||
|
||||
- Add `.github/workflows/publish-jetbrains-bundled.yml`.
|
||||
- Add a final success step to `publish-jetbrains.yml` that dispatches the bundle workflow with the merged release PR and merge commit.
|
||||
- The bundle workflow checks out the merged release PR for validation, then checks out the immutable `jetbrains/v<version>` tag, restores reviewed release metadata, builds the bundled variant, signs it, verifies it, and uploads `kilo-code-<version>-bundled.zip` to the same GitHub Release.
|
||||
- Bundle ZIPs are produced for RC and stable releases. Only stable releases update the custom plugin repository XML.
|
||||
|
||||
## Phase 4: GitHub Pages Repository
|
||||
|
||||
- Generate `jetbrains/updatePlugins.xml` from the signed bundled ZIP metadata on stable releases.
|
||||
- Point the plugin URL at the uploaded GitHub Release asset.
|
||||
- Deploy the XML with GitHub Pages Actions to `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
|
||||
- Users add that URL in JetBrains IDEs under Settings -> Plugins -> Manage Plugin Repositories.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Marketplace builds remain unchanged and download the CLI at runtime.
|
||||
- Bundled builds use the same tag and source, keep `kilo.cli.pinned=true`, and differ only by `-Pkilo.cli.bundled=true`.
|
||||
- Bundled ZIPs are signed and attached to the `jetbrains/v<version>` release.
|
||||
- Runtime extracts the bundled current-platform CLI and never downloads when `kilo-cli.zip` is present.
|
||||
- Stable releases update the GitHub Pages `updatePlugins.xml` with the latest bundled signed ZIP URL.
|
||||
@@ -1,5 +1,5 @@
|
||||
kotlin.stdlib.default.dependency=false
|
||||
kilo.jetbrains.version=7.0.10
|
||||
kilo.jetbrains.version=7.0.11
|
||||
# When true (default) the JetBrains plugin uses the pinned CLI release from package.json.
|
||||
# Set to false ONLY for local dev: generate the client from local source + bundle the local binary.
|
||||
# false is NOT releasable -- production builds fail unless this is true.
|
||||
|
||||
@@ -516,3 +516,10 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
}
|
||||
|
||||
/* Subagent card: the line renders inside the trigger's info row (an align-baseline flex),
|
||||
so force it onto its own full-width line directly under the title/description. */
|
||||
[data-slot="basic-tool-tool-info-main"] > [data-slot="tool-approval-line"] {
|
||||
flex-basis: 100%;
|
||||
padding: 2px 0 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { shouldRenderApprovalInBody } from "./basic-tool"
|
||||
|
||||
describe("shouldRenderApprovalInBody", () => {
|
||||
test("renders in the body by default when an approval exists", () => {
|
||||
expect(shouldRenderApprovalInBody(undefined, true)).toBe(true)
|
||||
expect(shouldRenderApprovalInBody("body", true)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not render when there is no approval", () => {
|
||||
expect(shouldRenderApprovalInBody("body", false)).toBe(false)
|
||||
expect(shouldRenderApprovalInBody(undefined, false)).toBe(false)
|
||||
})
|
||||
|
||||
test("never renders in the body for hidden placement, even with an approval", () => {
|
||||
expect(shouldRenderApprovalInBody("hidden", true)).toBe(false)
|
||||
expect(shouldRenderApprovalInBody("hidden", false)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ export interface BasicToolProps extends BaseProps {
|
||||
tool?: string
|
||||
callID?: string
|
||||
partID?: string
|
||||
approvalPlacement?: "body" | "hidden"
|
||||
}
|
||||
|
||||
type OpenProps = Pick<BasicToolProps, "tool" | "callID" | "partID" | "forceOpen" | "defaultOpen">
|
||||
@@ -19,26 +20,41 @@ export function initialOpen(props: OpenProps) {
|
||||
return props.forceOpen ? true : readToolOpen(toolOpenKey(props), props.defaultOpen)
|
||||
}
|
||||
|
||||
export function useToolApprovalLine() {
|
||||
const approval = useToolApproval()
|
||||
return () => {
|
||||
const value = approval()
|
||||
return value ? <ToolApprovalLine display={value} /> : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether BasicTool should inject the approval line into its body.
|
||||
*/
|
||||
export function shouldRenderApprovalInBody(placement: BasicToolProps["approvalPlacement"], hasApproval: boolean) {
|
||||
return placement !== "hidden" && hasApproval
|
||||
}
|
||||
|
||||
export function BasicTool(props: BasicToolProps) {
|
||||
const key = () => toolOpenKey(props)
|
||||
const initial = () => initialOpen(props)
|
||||
const approval = useToolApproval()
|
||||
const inBody = () => shouldRenderApprovalInBody(props.approvalPlacement, approval() !== undefined)
|
||||
const change = (open: boolean) => {
|
||||
writeToolOpen(key(), open)
|
||||
props.onOpenChange?.(open)
|
||||
}
|
||||
// The "why was this allowed" line lives in the expanded body, above any tool-specific details.
|
||||
const details = () => (
|
||||
<div data-slot="basic-tool-details">
|
||||
<Show when={approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
|
||||
<Show when={inBody() && approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
if (!("children" in props) && !approval()) {
|
||||
if (!("children" in props) && !inBody()) {
|
||||
return <Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} />
|
||||
}
|
||||
return (
|
||||
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails>
|
||||
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails={inBody()}>
|
||||
{details()}
|
||||
</Base>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useData } from "../context"
|
||||
import { useFileComponent } from "../context/file"
|
||||
import { useDialog } from "../context/dialog"
|
||||
import { type UiI18n, useI18n } from "../context/i18n"
|
||||
import { GenericTool, BasicTool } from "./basic-tool"
|
||||
import { BasicTool, useToolApprovalLine } from "./basic-tool"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Card } from "./card"
|
||||
@@ -1311,7 +1311,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
}}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<ToolApprovalProvider value={() => resolveToolApproval(meta(), i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string)}>
|
||||
<ToolApprovalProvider
|
||||
value={() =>
|
||||
resolveToolApproval(
|
||||
meta(),
|
||||
i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Dynamic
|
||||
component={render()}
|
||||
input={input()}
|
||||
@@ -1497,9 +1504,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.throughput}>
|
||||
{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}
|
||||
</Show>
|
||||
<Show when={props.throughput}>{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary()}>
|
||||
@@ -2172,6 +2177,8 @@ ToolRegistry.register({
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const approvalLine = useToolApprovalLine()
|
||||
|
||||
const trigger = () => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
@@ -2190,11 +2197,22 @@ ToolRegistry.register({
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
{/* Keep the auto-approve line attached to the subagent card instead of forcing a collapsible body. */}
|
||||
{approvalLine()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return <BasicTool hideDetails icon="task" status={props.status} trigger={trigger()} animated />
|
||||
return (
|
||||
<BasicTool
|
||||
hideDetails
|
||||
approvalPlacement="hidden"
|
||||
icon="task"
|
||||
status={props.status}
|
||||
trigger={trigger()}
|
||||
animated
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2932,6 +2950,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
approvalPlacement="hidden"
|
||||
icon="checklist"
|
||||
trigger={
|
||||
<ToolTriggerRow
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function changed<T>(current: T | undefined, next: T | undefined, key: (item: T) => string) {
|
||||
if (current === undefined || next === undefined) return current !== next
|
||||
return key(current) !== key(next)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { changed } from "./select-change"
|
||||
|
||||
describe("changed", () => {
|
||||
const key = (item: { value: string }) => item.value
|
||||
|
||||
test("ignores recreated options with the current key", () => {
|
||||
expect(changed({ value: "ollama" }, { value: "ollama" }, key)).toBe(false)
|
||||
})
|
||||
|
||||
test("reports selected and cleared values", () => {
|
||||
expect(changed({ value: "ollama" }, { value: "kilo" }, key)).toBe(true)
|
||||
expect(changed({ value: "ollama" }, undefined, key)).toBe(true)
|
||||
expect(changed(undefined, { value: "ollama" }, key)).toBe(true)
|
||||
expect(changed(undefined, undefined, key)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1 +1,19 @@
|
||||
import { Select as Base, type SelectProps } from "@opencode-ai/ui/select"
|
||||
import type { ButtonProps } from "@opencode-ai/ui/button"
|
||||
import { changed } from "./select-change"
|
||||
|
||||
export * from "@opencode-ai/ui/select"
|
||||
|
||||
export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">) {
|
||||
const key = (item: T) => (props.value ? props.value(item) : (item as string))
|
||||
|
||||
return (
|
||||
<Base
|
||||
{...props}
|
||||
onSelect={(next) => {
|
||||
if (!changed(props.current, next, key)) return
|
||||
props.onSelect?.(next)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveToolApproval } from "./tool-approval"
|
||||
|
||||
// Echo the key + params so assertions can see which string was chosen without a real dictionary.
|
||||
const t = (key: string, params?: Record<string, string | number | boolean>) =>
|
||||
params
|
||||
? `${key}(${Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(",")})`
|
||||
: key
|
||||
|
||||
describe("resolveToolApproval", () => {
|
||||
test("returns undefined when there is no approval on the metadata", () => {
|
||||
expect(resolveToolApproval(undefined, t)).toBeUndefined()
|
||||
expect(resolveToolApproval({ other: 1 }, t)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("manual approvals show only the decision, no source or rule", () => {
|
||||
const out = resolveToolApproval({ approval: { source: "manual" } }, t)
|
||||
expect(out).toEqual({
|
||||
approval: { source: "manual" },
|
||||
decision: "ui.approval.manual",
|
||||
source: undefined,
|
||||
rule: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("a specific rule is shown with permission + pattern", () => {
|
||||
const approval = { source: "project" as const, rule: { permission: "bash", pattern: "git *", action: "allow" } }
|
||||
const out = resolveToolApproval({ approval }, t)
|
||||
expect(out?.decision).toBe("ui.approval.auto")
|
||||
expect(out?.source).toBe("ui.approval.source.project")
|
||||
expect(out?.rule).toBe("ui.approval.rule(permission=bash,pattern=git *)")
|
||||
})
|
||||
|
||||
test("a per-tool rule with a wildcard pattern still shows the tool name", () => {
|
||||
const approval = {
|
||||
source: "agent" as const,
|
||||
agent: "explore",
|
||||
rule: { permission: "task", pattern: "*", action: "allow" },
|
||||
}
|
||||
const out = resolveToolApproval({ approval }, t)
|
||||
expect(out?.rule).toBe("ui.approval.rule(permission=task,pattern=*)")
|
||||
})
|
||||
|
||||
test("the catch-all */* rule is dropped so the line is not noisy for blanket agent defaults", () => {
|
||||
// e.g. the code agent auto-approving `task`/`todowrite` via its "*": "allow" default.
|
||||
const approval = {
|
||||
source: "agent" as const,
|
||||
agent: "code",
|
||||
rule: { permission: "*", pattern: "*", action: "allow" },
|
||||
}
|
||||
const out = resolveToolApproval({ approval }, t)
|
||||
expect(out?.source).toBe("ui.approval.source.agent(agent=code)")
|
||||
expect(out?.rule).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -59,13 +59,18 @@ export function resolveToolApproval(
|
||||
if (approval.source === "manual") return undefined
|
||||
return t(`ui.approval.source.${approval.source}`)
|
||||
}
|
||||
const rule = approval.rule
|
||||
// The catch-all "*"/"*" rule carries no useful detail (it's the blanket allow-everything default),
|
||||
// so drop the "matched `*` rule `*`" fragment and let the source alone explain the approval.
|
||||
const ruleText =
|
||||
rule && !(rule.permission === "*" && rule.pattern === "*")
|
||||
? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern })
|
||||
: undefined
|
||||
return {
|
||||
approval,
|
||||
decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"),
|
||||
source: sourceText(),
|
||||
rule: approval.rule
|
||||
? t("ui.approval.rule", { permission: approval.rule.permission, pattern: approval.rule.pattern })
|
||||
: undefined,
|
||||
rule: ruleText,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +81,7 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
|
||||
<div data-slot="tool-approval-line" data-source={props.display.approval.source}>
|
||||
<span data-slot="tool-approval-decision">{props.display.decision}</span>
|
||||
<Show when={!manual()}>
|
||||
<Show when={props.display.source}>
|
||||
{(text) => <span data-slot="tool-approval-source">{text()}</span>}
|
||||
</Show>
|
||||
<Show when={props.display.source}>{(text) => <span data-slot="tool-approval-source">{text()}</span>}</Show>
|
||||
<Show when={props.display.rule}>{(text) => <span data-slot="tool-approval-rule">{text()}</span>}</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -804,13 +804,13 @@
|
||||
"command": "kilo-code.new.cycleAgentMode",
|
||||
"key": "ctrl+.",
|
||||
"mac": "cmd+.",
|
||||
"when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'"
|
||||
"when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.cyclePreviousAgentMode",
|
||||
"key": "ctrl+shift+.",
|
||||
"mac": "cmd+shift+.",
|
||||
"when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'"
|
||||
"when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.autocomplete.cancelSuggestions",
|
||||
|
||||
@@ -741,6 +741,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private setSidebarVisible(visible: boolean): void {
|
||||
this.setStreamVisibility(visible)
|
||||
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", visible)
|
||||
if (!visible && this.opts.focusContext) {
|
||||
void vscode.commands.executeCommand("setContext", this.opts.focusContext, false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a WebviewPanel for displaying Kilo in an editor tab. */
|
||||
@@ -981,6 +984,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return
|
||||
}
|
||||
if (await this.handleModelSelectorExpandedMessage(message)) return
|
||||
this.handleWebviewFocusMessage(message)
|
||||
this.visibleTaskStreams.handle(message)
|
||||
if (await this.handleMemoryMessage(message)) return
|
||||
if (this.handleLegacyMigrationMessage(message)) return
|
||||
@@ -1466,6 +1470,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.webviewMessageDisposable = watchWorkStyleConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable)
|
||||
}
|
||||
|
||||
private handleWebviewFocusMessage(message: TypedWebviewMessage & { focused?: unknown }): void {
|
||||
if (message.type !== "webviewFocusChanged") return
|
||||
if (this.opts.focusContext) {
|
||||
void vscode.commands.executeCommand("setContext", this.opts.focusContext, message.focused === true)
|
||||
}
|
||||
}
|
||||
|
||||
private handleEditorOpenMessage(message: Parameters<typeof handleEditorAction>[0]): boolean {
|
||||
return handleEditorAction(message, {
|
||||
dir: () => this.getWorkspaceDirectory(this.currentSession?.id),
|
||||
@@ -4559,6 +4570,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
* Does NOT kill the server — that's the connection service's job.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.opts.focusContext) {
|
||||
void vscode.commands.executeCommand("setContext", this.opts.focusContext, false)
|
||||
}
|
||||
this.unsubscribeRemote?.()
|
||||
this.streams.focus(undefined)
|
||||
this.connectionService.unregisterVisible(this.instanceId)
|
||||
|
||||
@@ -64,7 +64,7 @@ export class SettingsEditorProvider implements vscode.Disposable {
|
||||
const provider = this.providers.get(view)
|
||||
provider?.postMessage({ type: "navigate", view, tab })
|
||||
}
|
||||
existing.reveal(vscode.ViewColumn.One)
|
||||
existing.reveal(vscode.ViewColumn.Active)
|
||||
this.providers.get(view)?.postMessage({ type: "navigate", view, ...(tab ? { tab } : {}) })
|
||||
return
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export class SettingsEditorProvider implements vscode.Disposable {
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
`kilo-code.new.${view}Panel`,
|
||||
PANEL_TITLES[view],
|
||||
vscode.ViewColumn.One,
|
||||
vscode.ViewColumn.Active,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
|
||||
@@ -122,7 +122,9 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
|
||||
// Create the provider with shared service
|
||||
const provider = new KiloProvider(context.extensionUri, connectionService, context)
|
||||
const provider = new KiloProvider(context.extensionUri, connectionService, context, {
|
||||
focusContext: "kilo-code.new.sidebarFocused",
|
||||
})
|
||||
provider.setRemoteService(remoteService)
|
||||
|
||||
// Register the webview view provider for the sidebar.
|
||||
@@ -592,7 +594,7 @@ export async function deactivate() {
|
||||
TelemetryProxy.getInstance().shutdown()
|
||||
}
|
||||
|
||||
async function openKiloInNewTab(
|
||||
function openKiloInNewTab(
|
||||
context: vscode.ExtensionContext,
|
||||
connectionService: KiloConnectionService,
|
||||
agentManagerProvider: AgentManagerProvider,
|
||||
@@ -601,20 +603,16 @@ async function openKiloInNewTab(
|
||||
remoteService: RemoteStatusService,
|
||||
autoApprove: ReturnType<typeof registerToggleAutoApprove>,
|
||||
) {
|
||||
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0)
|
||||
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
|
||||
|
||||
if (!hasVisibleEditors) {
|
||||
await vscode.commands.executeCommand("workbench.action.newGroupRight")
|
||||
}
|
||||
|
||||
const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two
|
||||
|
||||
const panel = vscode.window.createWebviewPanel("kilo-code.new.TabPanel", EXTENSION_DISPLAY_NAME, targetCol, {
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [context.extensionUri],
|
||||
})
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
"kilo-code.new.TabPanel",
|
||||
EXTENSION_DISPLAY_NAME,
|
||||
vscode.ViewColumn.Active,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [context.extensionUri],
|
||||
},
|
||||
)
|
||||
|
||||
panel.iconPath = {
|
||||
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "kilo-light.svg"),
|
||||
@@ -636,11 +634,6 @@ async function openKiloInNewTab(
|
||||
tabProvider.resolveWebviewPanel(panel)
|
||||
tabPanels.set(panel, tabProvider)
|
||||
|
||||
// Wait for the new panel to become active before locking the editor group.
|
||||
// This avoids the race where VS Code hasn't switched focus yet.
|
||||
await waitForWebviewPanelToBeActive(panel)
|
||||
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
|
||||
|
||||
panel.onDidDispose(
|
||||
() => {
|
||||
console.log("[Kilo New] Tab panel disposed")
|
||||
@@ -671,19 +664,3 @@ function ensureCommandsSkipShell(commands: string[]): void {
|
||||
if (missing.length === 0) return
|
||||
config.update("commandsToSkipShell", [...existing, ...missing], target)
|
||||
}
|
||||
|
||||
function waitForWebviewPanelToBeActive(panel: vscode.WebviewPanel): Promise<void> {
|
||||
if (panel.active) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const disposable = panel.onDidChangeViewState((event) => {
|
||||
if (!event.webviewPanel.active) {
|
||||
return
|
||||
}
|
||||
disposable.dispose()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type KiloProviderOptions = {
|
||||
/** Context key updated from focus events reported by this provider's webview. */
|
||||
focusContext?: string
|
||||
projectDirectory?: string | null
|
||||
platform?: string
|
||||
snapshotInitialization?: "wait"
|
||||
|
||||
@@ -16,6 +16,7 @@ const PKG_JSON_FILE = path.join(ROOT, "package.json")
|
||||
const SRC_DIR = path.join(ROOT, "src")
|
||||
const EXTENSION_FILE = path.join(ROOT, "src/extension.ts")
|
||||
const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
|
||||
const SETTINGS_PROVIDER_FILE = path.join(ROOT, "src/SettingsEditorProvider.ts")
|
||||
const VSCODE_HOST_FILE = path.join(ROOT, "src/agent-manager/vscode-host.ts")
|
||||
|
||||
function sliceBlock(source: string, start: number): string {
|
||||
@@ -126,6 +127,23 @@ describe("Extension — package.json command sync", () => {
|
||||
when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'",
|
||||
})
|
||||
})
|
||||
|
||||
it("scopes agent mode shortcuts to focused Kilo webviews", () => {
|
||||
const bindings = pkg.contributes?.keybindings?.filter(
|
||||
(item: { command: string }) =>
|
||||
item.command === "kilo-code.new.cycleAgentMode" || item.command === "kilo-code.new.cyclePreviousAgentMode",
|
||||
)
|
||||
const when =
|
||||
"kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'"
|
||||
|
||||
expect(bindings).toHaveLength(2)
|
||||
expect(bindings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ command: "kilo-code.new.cycleAgentMode", when }),
|
||||
expect.objectContaining({ command: "kilo-code.new.cyclePreviousAgentMode", when }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -202,6 +220,32 @@ describe("Extension — KiloProvider handler wiring", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Extension — editor panel placement", () => {
|
||||
const ext = fs.readFileSync(EXTENSION_FILE, "utf-8")
|
||||
const settings = fs.readFileSync(SETTINGS_PROVIDER_FILE, "utf-8")
|
||||
|
||||
it("opens Kilo as a tab in the active editor group", () => {
|
||||
const fn = ext.indexOf("function openKiloInNewTab")
|
||||
expect(fn, "openKiloInNewTab must exist").toBeGreaterThan(-1)
|
||||
const body = sliceBlock(ext, fn)
|
||||
|
||||
expect(body).toContain("vscode.ViewColumn.Active")
|
||||
expect(body).not.toContain("visibleTextEditors")
|
||||
expect(body).not.toContain("workbench.action.newGroupRight")
|
||||
expect(body).not.toContain("workbench.action.lockEditorGroup")
|
||||
})
|
||||
|
||||
it("opens and reveals Settings in the active editor group", () => {
|
||||
const fn = settings.indexOf("openPanel(view")
|
||||
expect(fn, "SettingsEditorProvider.openPanel must exist").toBeGreaterThan(-1)
|
||||
const body = sliceBlock(settings, fn)
|
||||
|
||||
expect(body).toContain("existing.reveal(vscode.ViewColumn.Active)")
|
||||
expect(body.match(/vscode\.ViewColumn\.Active/g)).toHaveLength(2)
|
||||
expect(body).not.toContain("vscode.ViewColumn.One")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KiloProvider — continueInWorktree error fallback
|
||||
//
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
cycleAgent,
|
||||
createDraftAgentSeed,
|
||||
draftAgentSelection,
|
||||
resolveSessionAgent,
|
||||
@@ -81,6 +82,54 @@ describe("resolveSessionAgent", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("cycleAgent", () => {
|
||||
const agents = [
|
||||
{ name: "ask", mode: "primary" },
|
||||
{ name: "plan", mode: "primary" },
|
||||
{ name: "task", mode: "subagent" },
|
||||
{ name: "hidden", mode: "primary", hidden: true },
|
||||
{ name: "code", mode: "primary" },
|
||||
]
|
||||
|
||||
function cycle(current: string, direction: 1 | -1, scope = "pending-1") {
|
||||
const calls: Array<[string, string | undefined]> = []
|
||||
const name = cycleAgent({
|
||||
agents,
|
||||
scope,
|
||||
direction,
|
||||
selected: (id) => {
|
||||
expect(id).toBe(scope)
|
||||
return current
|
||||
},
|
||||
select: (agent, id) => calls.push([agent, id]),
|
||||
})
|
||||
return { name, calls }
|
||||
}
|
||||
|
||||
it("cycles the same pending scope read by the visible selector", () => {
|
||||
expect(cycle("ask", 1)).toEqual({ name: "plan", calls: [["plan", "pending-1"]] })
|
||||
expect(cycle("ask", -1)).toEqual({ name: "code", calls: [["code", "pending-1"]] })
|
||||
})
|
||||
|
||||
it("wraps and starts from the first agent when the selection is unknown", () => {
|
||||
expect(cycle("code", 1).name).toBe("ask")
|
||||
expect(cycle("missing", 1).name).toBe("ask")
|
||||
})
|
||||
|
||||
it("does nothing when there is no alternative", () => {
|
||||
const selected: string[] = []
|
||||
expect(
|
||||
cycleAgent({
|
||||
agents: [{ name: "code" }],
|
||||
direction: 1,
|
||||
selected: () => "code",
|
||||
select: (name) => selected.push(name),
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(selected).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("draftAgentSelection", () => {
|
||||
it("carries a pending agent into a new draft scope", () => {
|
||||
const result = draftAgentSelection({}, "draft-1", "plan")
|
||||
|
||||
@@ -160,6 +160,7 @@ import { buildShortcutCategories } from "./shortcuts"
|
||||
import { tracker } from "./telemetry"
|
||||
import "./agent-manager.css"
|
||||
import "./agent-manager-review.css"
|
||||
import { cycleAgent as cycle } from "../src/context/session-agent"
|
||||
const REVIEW_TAB_ID = "review"
|
||||
|
||||
interface SetupState {
|
||||
@@ -1071,14 +1072,14 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
|
||||
const cycleAgent = (direction: 1 | -1) => {
|
||||
const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden)
|
||||
if (available.length <= 1) return
|
||||
const current = session.selectedAgent()
|
||||
const idx = available.findIndex((a) => a.name === current)
|
||||
const raw = idx + direction
|
||||
const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw
|
||||
const agent = available[next]
|
||||
if (agent) session.selectAgent(agent.name)
|
||||
const id = session.currentSessionID() ?? activePendingId()
|
||||
cycle({
|
||||
agents: session.agents(),
|
||||
scope: id,
|
||||
direction,
|
||||
selected: session.selectedAgent,
|
||||
select: session.selectAgent,
|
||||
})
|
||||
}
|
||||
|
||||
const syncRunStatuses = (items: RunStatus[] = []) => {
|
||||
|
||||
@@ -42,6 +42,7 @@ import { FeedbackProvider } from "./context/feedback"
|
||||
import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models"
|
||||
import { ImageModelsProvider } from "./context/image-models"
|
||||
import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2"
|
||||
import { cycleAgent as cycle } from "./context/session-agent"
|
||||
import "./styles/chat.css"
|
||||
|
||||
type ViewType = "newTask" | "history" | "profile" | "settings" | "subAgentViewer"
|
||||
@@ -276,14 +277,14 @@ const AppContent: Component = () => {
|
||||
}
|
||||
|
||||
const cycleAgent = (direction: 1 | -1) => {
|
||||
const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden)
|
||||
if (available.length <= 1) return
|
||||
const current = session.selectedAgent()
|
||||
const idx = available.findIndex((a) => a.name === current)
|
||||
const raw = idx + direction
|
||||
const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw
|
||||
const agent = available[next]
|
||||
if (agent) session.selectAgent(agent.name)
|
||||
const id = session.currentSessionID() ?? tabs?.pending() ?? session.draftSessionID()
|
||||
cycle({
|
||||
agents: session.agents(),
|
||||
scope: id,
|
||||
direction,
|
||||
selected: session.selectedAgent,
|
||||
select: session.selectAgent,
|
||||
})
|
||||
}
|
||||
|
||||
const handleForked = (message: { type?: string; sessionID?: string; forkedFromID?: string }) => {
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type { Message } from "../types/messages"
|
||||
|
||||
export function cycleAgent(input: {
|
||||
agents: Array<{ name: string; mode?: string; hidden?: boolean }>
|
||||
scope?: string
|
||||
direction: 1 | -1
|
||||
selected: (scope?: string) => string
|
||||
select: (name: string, scope?: string) => void
|
||||
}) {
|
||||
const available = input.agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
if (available.length <= 1) return
|
||||
const index = available.findIndex((agent) => agent.name === input.selected(input.scope))
|
||||
const raw = index + input.direction
|
||||
const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw
|
||||
const name = available[next]?.name
|
||||
if (name) input.select(name, input.scope)
|
||||
return name
|
||||
}
|
||||
|
||||
export function resolveSessionAgent(messages: Message[], names: Set<string>): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const name = messages[i]?.agent?.trim()
|
||||
|
||||
@@ -55,6 +55,10 @@ export const VSCodeProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
const reportFocus = () => api.postMessage({ type: "webviewFocusChanged", focused: document.hasFocus() })
|
||||
window.addEventListener("focus", reportFocus)
|
||||
window.addEventListener("blur", reportFocus)
|
||||
reportFocus()
|
||||
handlers.add((message) => {
|
||||
if (message?.type === "modelSelectorExpandedLoaded") setExpanded(message.value)
|
||||
})
|
||||
@@ -62,6 +66,8 @@ export const VSCodeProvider: ParentComponent = (props) => {
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
window.removeEventListener("focus", reportFocus)
|
||||
window.removeEventListener("blur", reportFocus)
|
||||
handlers.clear()
|
||||
})
|
||||
|
||||
|
||||
@@ -175,6 +175,11 @@ export interface WebviewReadyRequest {
|
||||
type: "webviewReady"
|
||||
}
|
||||
|
||||
export interface WebviewFocusChangedRequest {
|
||||
type: "webviewFocusChanged"
|
||||
focused: boolean
|
||||
}
|
||||
|
||||
export interface SelectSourceRequest {
|
||||
type: "selectSource"
|
||||
id: string
|
||||
@@ -1257,6 +1262,7 @@ export type WebviewMessage =
|
||||
| CancelLoginRequest
|
||||
| SetOrganizationRequest
|
||||
| WebviewReadyRequest
|
||||
| WebviewFocusChangedRequest
|
||||
| SelectSourceRequest
|
||||
| RequestProvidersMessage
|
||||
| CompactRequest
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { createRequire } from "module"
|
||||
|
||||
export namespace TestCli {
|
||||
export const ENV = "KILO_TEST_CLI_PATH"
|
||||
|
||||
export async function build(root: string, dir: string) {
|
||||
if (path.resolve(process.cwd()) !== path.resolve(root)) {
|
||||
throw new Error(`CLI test bundle must be built from ${root}`)
|
||||
}
|
||||
const { createSolidTransformPlugin } = await import("@opentui/solid/bun-plugin")
|
||||
const entry = "./src/index.ts"
|
||||
const out = path.join(dir, "src/storage")
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entry],
|
||||
outdir: out,
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
conditions: ["browser"],
|
||||
plugins: [createSolidTransformPlugin()],
|
||||
// Keep the native TUI variants dynamic and the memory package singleton shared.
|
||||
external: ["node-gyp", "@opentui/core-*", "@kilocode/kilo-memory", "@kilocode/kilo-memory/*"],
|
||||
naming: { entry: "cli.js", asset: "[name]-[hash].[ext]" },
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, "Failed to build CLI subprocess test bundle")
|
||||
await fs.cp(path.join(root, "migration"), path.join(dir, "migration"), { recursive: true })
|
||||
// Resolve through Node's lookup from the package root: Bun's isolated layout does not
|
||||
// materialize package-level node_modules on every platform (e.g. the Windows runners).
|
||||
const req = createRequire(path.join(root, "package.json"))
|
||||
const core = path.dirname(req.resolve("@opentui/core"))
|
||||
const meta = JSON.parse(await Bun.file(path.join(core, "package.json")).text())
|
||||
const scope = path.join(dir, "node_modules/@opentui")
|
||||
await fs.mkdir(scope, { recursive: true })
|
||||
// Anchor variant lookup to the core package so links stay inside the same install tree.
|
||||
const deps = createRequire(path.join(core, "package.json"))
|
||||
const kind = process.platform === "win32" ? "junction" : "dir"
|
||||
for (const name of Object.keys(meta.optionalDependencies ?? {})) {
|
||||
const target = await (async () => {
|
||||
try {
|
||||
return path.dirname(deps.resolve(name))
|
||||
} catch {
|
||||
// Optional native variant is not installed for this platform.
|
||||
return
|
||||
}
|
||||
})()
|
||||
if (target) await fs.symlink(target, path.join(scope, name.replace("@opentui/", "")), kind)
|
||||
}
|
||||
return path.join(out, "cli.js")
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export namespace TestProfile {
|
||||
"cli/serve/*.test.ts",
|
||||
"kilocode/background-process.test.ts",
|
||||
"kilocode/cli/install-artifact.test.ts",
|
||||
"kilocode/cli/tui/thread.test.ts",
|
||||
"kilocode/core-watcher.test.ts",
|
||||
"kilocode/interactive-terminal.test.ts",
|
||||
"tool/shell.test.ts",
|
||||
|
||||
@@ -9,6 +9,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { TestProfile } from "./kilocode/test-profile"
|
||||
import { TestShard } from "./kilocode/test-shard"
|
||||
import { TestCli } from "./kilocode/test-cli"
|
||||
import { remove } from "../test/kilocode/cleanup"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "..")
|
||||
@@ -178,12 +179,33 @@ type Result = {
|
||||
attempts: number
|
||||
}
|
||||
|
||||
type Proc = ReturnType<typeof Bun.spawn>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : ""
|
||||
if (ci) await fs.mkdir(xmldir, { recursive: true })
|
||||
const supplied = process.env[TestCli.ENV]
|
||||
const binprefix = path.join(root, ".artifacts", "test-cli-")
|
||||
const built = supplied
|
||||
? { binary: supplied, dir: undefined }
|
||||
: await (async () => {
|
||||
await fs.mkdir(path.dirname(binprefix), { recursive: true })
|
||||
const dir = await fs.mkdtemp(binprefix)
|
||||
return { binary: await TestCli.build(root, dir), dir }
|
||||
})()
|
||||
|
||||
async function cleanBinary() {
|
||||
if (!built.dir) return
|
||||
const expected = path.dirname(binprefix)
|
||||
const valid =
|
||||
path.dirname(built.dir) === expected && path.basename(built.dir).startsWith(path.basename(binprefix))
|
||||
if (!valid) throw new Error(`Refusing to remove unexpected test CLI directory: ${built.dir}`)
|
||||
// The generated directory contains the bundle, emitted assets, and copied migrations.
|
||||
await fs.rm(built.dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const counter = { done: 0 }
|
||||
const pad = String(files.length).length
|
||||
@@ -200,6 +222,74 @@ const marks = {
|
||||
} as const
|
||||
const legend = `Legend: ${marks.pass}=pass ${marks.retry}=pass-after-retry ${marks.fail}=fail ${marks.timeout}=timeout`
|
||||
|
||||
function drain(stream: ReadableStream<Uint8Array>) {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const promise = (async () => {
|
||||
let text = ""
|
||||
while (true) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) return text + decoder.decode()
|
||||
text += decoder.decode(chunk.value, { stream: true })
|
||||
}
|
||||
})()
|
||||
return {
|
||||
promise,
|
||||
close: () => reader.cancel().catch(() => undefined),
|
||||
}
|
||||
}
|
||||
|
||||
async function signal(proc: Proc, sig: "SIGTERM" | "SIGKILL") {
|
||||
if (process.platform === "win32") {
|
||||
const args = ["/pid", String(proc.pid), "/T"]
|
||||
if (sig === "SIGKILL") args.push("/F")
|
||||
const kill = Bun.spawn(["taskkill", ...args], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
await kill.exited
|
||||
return
|
||||
}
|
||||
|
||||
const tree = Bun.spawn(["ps", "-axo", "pid=,ppid="], {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const [code, text] = await Promise.all([tree.exited, new Response(tree.stdout).text()])
|
||||
const rows = code === 0 ? text.trim().split("\n") : []
|
||||
const children = new Map<number, number[]>()
|
||||
for (const row of rows) {
|
||||
const [pid, parent] = row.trim().split(/\s+/).map(Number)
|
||||
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(parent)) continue
|
||||
const list = children.get(parent) ?? []
|
||||
list.push(pid)
|
||||
children.set(parent, list)
|
||||
}
|
||||
const collect = (pid: number): number[] => (children.get(pid) ?? []).flatMap((child) => [...collect(child), child])
|
||||
for (const pid of [...collect(proc.pid), proc.pid]) {
|
||||
for (const target of [-pid, pid]) {
|
||||
try {
|
||||
process.kill(target, sig)
|
||||
} catch (error) {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH") continue
|
||||
// A kill failure (e.g. EPERM in a sandboxed runner) must not take down the whole run.
|
||||
console.error(`warn: failed to signal ${target} with ${sig}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function terminate(proc: Proc) {
|
||||
if (proc.exitCode !== null) return
|
||||
await signal(proc, "SIGTERM")
|
||||
const exited = Symbol("exited")
|
||||
const result = await Promise.race([proc.exited.then(() => exited), Bun.sleep(2_000)])
|
||||
if (result === exited) return
|
||||
await signal(proc, "SIGKILL")
|
||||
await Promise.race([proc.exited, Bun.sleep(2_000)])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run a single test file
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -218,24 +308,36 @@ async function run(file: string): Promise<Result> {
|
||||
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: root,
|
||||
env: { ...process.env, [TestCli.ENV]: built.binary },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
detached: process.platform !== "win32",
|
||||
})
|
||||
active.set(proc.pid, proc)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
killed.value = true
|
||||
proc.kill()
|
||||
}, deadline)
|
||||
|
||||
const stdout = new Response(proc.stdout).text()
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
const code = await proc.exited.finally(async () => {
|
||||
clearTimeout(timer)
|
||||
const stdout = drain(proc.stdout)
|
||||
const stderr = drain(proc.stderr)
|
||||
const code = await Promise.race([
|
||||
proc.exited.then((value) => ({ timedout: false, value })),
|
||||
Bun.sleep(deadline).then(() => ({ timedout: true, value: -1 })),
|
||||
]).then(async (result) => {
|
||||
if (result.timedout) {
|
||||
killed.value = true
|
||||
await terminate(proc)
|
||||
}
|
||||
await finish(proc)
|
||||
return result.timedout ? (proc.exitCode ?? result.value) : result.value
|
||||
})
|
||||
const output = await Promise.race([
|
||||
Promise.all([stdout.promise, stderr.promise]).then((value) => ({ closed: true, value })),
|
||||
Bun.sleep(2_000).then(() => ({ closed: false, value: ["", ""] as [string, string] })),
|
||||
]).then(async (result) => {
|
||||
if (result.closed) return result.value
|
||||
await signal(proc, "SIGKILL")
|
||||
await Promise.all([stdout.close(), stderr.close()])
|
||||
return Promise.all([stdout.promise, stderr.promise])
|
||||
})
|
||||
const output = await Promise.all([stdout, stderr])
|
||||
|
||||
return {
|
||||
file,
|
||||
@@ -254,7 +356,7 @@ function finish(proc: ReturnType<typeof Bun.spawn>) {
|
||||
if (found) return found
|
||||
|
||||
const promise = (async () => {
|
||||
await proc.exited
|
||||
await Promise.race([proc.exited, Bun.sleep(2_000)])
|
||||
await cleanup(proc.pid)
|
||||
})().finally(() => {
|
||||
active.delete(proc.pid)
|
||||
@@ -269,10 +371,9 @@ function shutdown(code: number) {
|
||||
stopping.promise = (async () => {
|
||||
stopped.value = true
|
||||
const children = [...active.values()]
|
||||
for (const proc of children) {
|
||||
if (proc.exitCode === null) proc.kill("SIGKILL")
|
||||
}
|
||||
await Promise.all(children.map(terminate))
|
||||
await Promise.all(children.map(finish))
|
||||
await cleanBinary()
|
||||
process.exit(code)
|
||||
})()
|
||||
return stopping.promise
|
||||
@@ -450,6 +551,8 @@ if (ci) {
|
||||
})
|
||||
}
|
||||
|
||||
await cleanBinary()
|
||||
|
||||
process.exit(failures.length > 0 ? 1 : 0)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,33 +2,13 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
|
||||
import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement"
|
||||
import { context } from "@/project/instance-context"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Instance } from "@/kilocode/instance"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
function truncate(value: string, max: number) {
|
||||
return value.length > max ? value.slice(0, max) : value
|
||||
}
|
||||
|
||||
// kilocode_change start - K1 W1: extracted so the advertisement payload shape
|
||||
// is unit-testable as real behavior, rather than only through a source-text/
|
||||
// regex assertion on this file (the handler itself can't be driven end-to-end
|
||||
// — see the doc comment on `handler` below).
|
||||
export function buildInstanceAdvertisement(directory: string): {
|
||||
name: string
|
||||
projectName: string
|
||||
version: string
|
||||
} {
|
||||
return {
|
||||
name: truncate(os.hostname(), 64),
|
||||
projectName: truncate(path.basename(directory) || directory, 64),
|
||||
version: truncate(InstallationVersion, 32),
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
// Re-export so existing unit tests that import from this module keep working.
|
||||
export { buildInstanceAdvertisement }
|
||||
|
||||
export const RemoteCommand = cmd({
|
||||
command: "remote",
|
||||
@@ -41,6 +21,8 @@ export const RemoteCommand = cmd({
|
||||
// The process-wide `KILO_REMOTE_ATTACH_SESSION` guard was removed in K1
|
||||
// (in-process sessions only; no spawned children), so this is always
|
||||
// advertised for the explicit `kilo remote` command path.
|
||||
// enableRemote() also ensures a default advertisement; this explicit call
|
||||
// remains a legitimate replace (or no-op when identical) per the contract.
|
||||
KiloSessions.setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory))
|
||||
|
||||
await KiloSessions.enableRemote()
|
||||
|
||||
@@ -28,7 +28,6 @@ import { Agent } from "@/agent/agent"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { FormatError, FormatUnknownError } from "../error"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
||||
import { event as normalizeEvent } from "./run/event"
|
||||
import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
|
||||
import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change
|
||||
import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change
|
||||
@@ -728,9 +727,9 @@ export const RunCommand = effectCmd({
|
||||
let retries = 0 // kilocode_change
|
||||
let error: string | undefined
|
||||
|
||||
for await (const payload of events.stream) {
|
||||
const event = normalizeEvent(payload)
|
||||
if (!event) continue
|
||||
// kilocode_change start - revert to upstream: consume native events without normalizing sync copies
|
||||
for await (const event of events.stream) {
|
||||
// kilocode_change end
|
||||
|
||||
if (
|
||||
event.type === "message.updated" &&
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
// Demo mode also handles permission and question replies locally, completing
|
||||
// or failing the synthetic tool parts as appropriate.
|
||||
import path from "path"
|
||||
import type { ToolPart } from "@kilocode/sdk/v2"
|
||||
import type { Event } from "./event"
|
||||
import type { Event, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type
|
||||
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// kilocode_change - new file
|
||||
import type {
|
||||
Event as SDKEvent,
|
||||
GlobalEvent,
|
||||
SyncEventMessagePartRemoved,
|
||||
SyncEventMessagePartUpdated,
|
||||
SyncEventMessageRemoved,
|
||||
SyncEventMessageUpdated,
|
||||
} from "@kilocode/sdk/v2"
|
||||
|
||||
type MessageUpdated = {
|
||||
id: string
|
||||
type: "message.updated"
|
||||
properties: SyncEventMessageUpdated["syncEvent"]["data"]
|
||||
}
|
||||
|
||||
type MessageRemoved = {
|
||||
id: string
|
||||
type: "message.removed"
|
||||
properties: SyncEventMessageRemoved["syncEvent"]["data"]
|
||||
}
|
||||
|
||||
type MessagePartUpdated = {
|
||||
id: string
|
||||
type: "message.part.updated"
|
||||
properties: SyncEventMessagePartUpdated["syncEvent"]["data"]
|
||||
}
|
||||
|
||||
type MessagePartRemoved = {
|
||||
id: string
|
||||
type: "message.part.removed"
|
||||
properties: SyncEventMessagePartRemoved["syncEvent"]["data"]
|
||||
}
|
||||
|
||||
export type Event = SDKEvent | MessageUpdated | MessageRemoved | MessagePartUpdated | MessagePartRemoved
|
||||
|
||||
export function event(payload: GlobalEvent["payload"]): Event | undefined {
|
||||
if (payload.type !== "sync") return payload
|
||||
|
||||
const sync = payload.syncEvent
|
||||
switch (sync.type) {
|
||||
case "message.updated.1":
|
||||
return { id: sync.id, type: "message.updated", properties: sync.data }
|
||||
case "message.removed.1":
|
||||
return { id: sync.id, type: "message.removed", properties: sync.data }
|
||||
case "message.part.updated.1":
|
||||
return { id: sync.id, type: "message.part.updated", properties: sync.data }
|
||||
case "message.part.removed.1":
|
||||
return { id: sync.id, type: "message.part.removed", properties: sync.data }
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,8 @@
|
||||
// `data.questions`. The footer shows whichever is first. When a reply
|
||||
// event arrives, the queue entry is removed and the footer falls back
|
||||
// to the next pending request or to the prompt view.
|
||||
import type { Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2"
|
||||
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type
|
||||
import type { RunInteractiveTerminalSnapshot } from "@/kilocode/cli/cmd/run/types" // kilocode_change
|
||||
import type { Event } from "./event"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output" // kilocode_change
|
||||
import { toolView } from "./tool"
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
// The tick counter prevents stale idle events from resolving the wrong turn.
|
||||
// We also re-check live session status before resolving an idle event so a
|
||||
// delayed idle from an older turn cannot complete a newer busy turn.
|
||||
import type { GlobalEvent, KiloClient } from "@kilocode/sdk/v2"
|
||||
import { event as normalizeEvent, type Event } from "./event"
|
||||
import type { Event, GlobalEvent, KiloClient } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import {
|
||||
@@ -191,8 +190,10 @@ function globalPayloadEvent(value: unknown): Event | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payload = normalizeEvent(value.payload)
|
||||
return payload && isEvent(payload) ? payload : undefined
|
||||
// kilocode_change start - revert to upstream: ignore sync compatibility copies
|
||||
if (value.payload.type === "sync") return undefined
|
||||
return isEvent(value.payload) ? value.payload : undefined
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2"
|
||||
import type { Event } from "./event"
|
||||
import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type
|
||||
import * as Locale from "@/util/locale"
|
||||
import {
|
||||
bootstrapSessionData,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change
|
||||
import { startParentWatchdog } from "../../kilocode/parent-watchdog" // kilocode_change
|
||||
import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change
|
||||
|
||||
export const ServeCommand = effectCmd({
|
||||
command: "serve",
|
||||
@@ -38,6 +39,7 @@ export const ServeCommand = effectCmd({
|
||||
const shutdown = async () => {
|
||||
stopWatchdog()
|
||||
try {
|
||||
await KiloSessions.drainIngestForShutdown() // kilocode_change
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
await server.stop(true)
|
||||
} finally {
|
||||
|
||||
@@ -283,6 +283,9 @@ export const TuiThreadCommand = cmd({
|
||||
}
|
||||
process.once("SIGHUP", () => shutdownAndExit({ reason: "signal", signal: "SIGHUP", code: 129 }))
|
||||
process.once("SIGTERM", () => shutdownAndExit({ reason: "signal", signal: "SIGTERM", code: 143 }))
|
||||
// kilocode_change - external kill -INT takes the same graceful path as SIGHUP/SIGTERM.
|
||||
// Interactive Ctrl-C in the TUI is a raw-mode keypress, not a signal.
|
||||
process.once("SIGINT", () => shutdownAndExit({ reason: "signal", signal: "SIGINT", code: 130 }))
|
||||
// In some terminal/tab-close paths the parent shell is terminated without
|
||||
// forwarding a signal to this process, leaving the TUI orphaned. Detect
|
||||
// parent PID re-parenting and exit explicitly.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// kilocode_change - new file
|
||||
// Pure shutdown sequence for the embedded TUI worker. Extracted so unit tests can
|
||||
// assert drain → dispose → stopServer ordering without loading worker.ts side effects.
|
||||
export function createWorkerShutdown(input: {
|
||||
drain: () => Promise<void>
|
||||
dispose: () => Promise<void>
|
||||
stopServer: () => Promise<void>
|
||||
}) {
|
||||
return async () => {
|
||||
await input.drain()
|
||||
await input.dispose()
|
||||
await input.stopServer()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy
|
||||
import { KiloLog } from "@/kilocode/log" // kilocode_change
|
||||
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" // kilocode_change
|
||||
import { createWorkerRemoteExit } from "@/kilocode/cli/cmd/tui/remote-exit-worker" // kilocode_change
|
||||
import { createWorkerShutdown } from "@/cli/tui/worker-shutdown" // kilocode_change
|
||||
import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change
|
||||
|
||||
ensureProcessMetadata("worker") // kilocode_change - retain worker role and parent run correlation
|
||||
await KiloLog.init() // kilocode_change - keep compatibility logs off the TUI terminal
|
||||
@@ -25,6 +27,15 @@ GlobalBus.on("event", (event) => {
|
||||
|
||||
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
const remoteExit = createWorkerRemoteExit(Rpc.emit) // kilocode_change
|
||||
// kilocode_change start - drain ingest before dispose so GlobalBus/remote stay live
|
||||
const runShutdown = createWorkerShutdown({
|
||||
drain: () => KiloSessions.drainIngestForShutdown(),
|
||||
dispose: () => InstanceRuntime.disposeAllInstances(),
|
||||
stopServer: async () => {
|
||||
if (server) await server.stop(true)
|
||||
},
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
export const rpc = {
|
||||
// kilocode_change start - worker lifecycle hooks for remote exit
|
||||
@@ -78,8 +89,7 @@ export const rpc = {
|
||||
},
|
||||
async shutdown() {
|
||||
remoteExit.shutdown() // kilocode_change
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
if (server) await server.stop(true)
|
||||
await runShutdown() // kilocode_change - drain → dispose → stopServer
|
||||
// kilocode_change start - Clear the Rpc message channel so the worker's event loop can drain and
|
||||
// exit naturally. Without this, the active onmessage handle keeps the
|
||||
// worker alive even after all async work is done.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Once-per-process guard around the session ingest shutdown drain.
|
||||
// Overlapping shutdown paths (worker RPC, KiloShutdown, serve signals) must not double-POST.
|
||||
// The guarded call never rejects: a drain failure must not block the remaining shutdown
|
||||
// sequence (disposeAllInstances / server.stop). Failures are logged once via the optional
|
||||
// onError callback; later callers share the same resolved promise (no retry).
|
||||
export namespace IngestDrain {
|
||||
export function create(run: () => Promise<void>, onError?: (err: unknown) => void) {
|
||||
let done: Promise<void> | undefined
|
||||
return () => {
|
||||
if (!done) {
|
||||
done = run().catch((err) => {
|
||||
onError?.(err)
|
||||
})
|
||||
}
|
||||
return done
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,8 @@ export namespace IngestQueue {
|
||||
// To avoid spamming the server, we coalesce updates and flush at most once per ~1s per session.
|
||||
//
|
||||
// `due` is the earliest time we should flush; it is also used to respect backoff when retries are
|
||||
// active. A later `due` always wins over an earlier one.
|
||||
// active. A later `due` always wins over an earlier one for non-terminal batches. Terminal batches
|
||||
// (`session_close`) may pull the flush earlier.
|
||||
const queue = new Map<string, { timeout: Timer; due: number; data: Map<string, Data> }>()
|
||||
|
||||
// Per-session retry state.
|
||||
@@ -94,6 +95,18 @@ export namespace IngestQueue {
|
||||
// - Store `until` so sync() can avoid scheduling a flush before backoff expires
|
||||
const retry = new Map<string, { count: number; until: number }>()
|
||||
|
||||
// In-flight flush promises. flush() deletes the queue entry before I/O, so an empty queue is not
|
||||
// quiescence — drain must join these too.
|
||||
const inflight = new Set<Promise<void>>()
|
||||
|
||||
// Last successfully resolved client and per-session share. Drain falls back to these when
|
||||
// getClient/getShare fail during teardown (e.g. authValid HTTP check).
|
||||
let cached: Client | undefined
|
||||
const shares = new Map<string, Share>()
|
||||
|
||||
// Shutdown mode: one attempt per item, no re-enqueue, use cached client/share on resolution failure.
|
||||
let shutting = false
|
||||
|
||||
const now = options.now ?? (() => Date.now())
|
||||
const set = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clear = options.clearTimeout ?? ((timer) => clearTimeout(timer))
|
||||
@@ -154,12 +167,12 @@ export namespace IngestQueue {
|
||||
return models.length > 0 ? `model:${models}` : ulid()
|
||||
}
|
||||
|
||||
function schedule(sessionId: string, due: number, data: Map<string, Data>) {
|
||||
function schedule(sessionId: string, due: number, data: Map<string, Data>, terminal = false) {
|
||||
const existing = queue.get(sessionId)
|
||||
if (existing) {
|
||||
// Don't reschedule if an earlier flush is already planned.
|
||||
// We only move the flush later (e.g., to respect backoff).
|
||||
if (existing.due >= due) return
|
||||
// Non-terminal: only move the flush later (e.g., to respect backoff).
|
||||
// Terminal (`session_close`): may pull the flush earlier so the tail is not left behind.
|
||||
if (!terminal && existing.due >= due) return
|
||||
clear(existing.timeout)
|
||||
}
|
||||
|
||||
@@ -170,7 +183,7 @@ export namespace IngestQueue {
|
||||
queue.set(sessionId, { timeout, due, data })
|
||||
}
|
||||
|
||||
function enqueue(sessionId: string, items: Data[], mode: "overwrite" | "fill", due: number) {
|
||||
function enqueue(sessionId: string, items: Data[], mode: "overwrite" | "fill", due: number, terminal = false) {
|
||||
const existing = queue.get(sessionId)
|
||||
if (existing) {
|
||||
for (const item of items) {
|
||||
@@ -180,7 +193,7 @@ export namespace IngestQueue {
|
||||
if (mode === "fill" && existing.data.has(k)) continue
|
||||
existing.data.set(k, item)
|
||||
}
|
||||
schedule(sessionId, due, existing.data)
|
||||
schedule(sessionId, due, existing.data, terminal)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,7 +202,12 @@ export namespace IngestQueue {
|
||||
data.set(key(item), item)
|
||||
}
|
||||
|
||||
schedule(sessionId, due, data)
|
||||
schedule(sessionId, due, data, terminal)
|
||||
}
|
||||
|
||||
function requeue(sessionId: string, items: Data[], delay: number) {
|
||||
if (shutting) return
|
||||
enqueue(sessionId, items, "fill", now() + delay)
|
||||
}
|
||||
|
||||
async function flush(sessionId: string) {
|
||||
@@ -204,13 +222,49 @@ export namespace IngestQueue {
|
||||
queue.delete(sessionId)
|
||||
|
||||
const items = Array.from(queued.data.values())
|
||||
|
||||
const done = run(sessionId, items)
|
||||
inflight.add(done)
|
||||
try {
|
||||
const share = await options.getShare(sessionId).catch(() => undefined)
|
||||
if (!share) return
|
||||
await done
|
||||
} finally {
|
||||
inflight.delete(done)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await options.getClient()
|
||||
if (!client) return
|
||||
async function resolveShare(sessionId: string) {
|
||||
const fresh = await options.getShare(sessionId).catch(() => undefined)
|
||||
if (fresh) shares.set(sessionId, fresh)
|
||||
return fresh ?? (shutting ? shares.get(sessionId) : undefined)
|
||||
}
|
||||
|
||||
async function resolveClient() {
|
||||
// Preserve normal-path throw → outer catch logging; only swallow during shutdown so the
|
||||
// cached client can be used.
|
||||
const fresh = await options.getClient().catch((error) => {
|
||||
if (!shutting) throw error
|
||||
return undefined
|
||||
})
|
||||
if (fresh) cached = fresh
|
||||
return fresh ?? (shutting ? cached : undefined)
|
||||
}
|
||||
|
||||
async function run(sessionId: string, items: Data[]) {
|
||||
try {
|
||||
const share = await resolveShare(sessionId)
|
||||
if (!share) {
|
||||
if (shutting) {
|
||||
options.log.error("ingest drain skipped", { sessionId, reason: "no share" })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const client = await resolveClient()
|
||||
if (!client) {
|
||||
if (shutting) {
|
||||
options.log.error("ingest drain skipped", { sessionId, reason: "no client" })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (options.log.info) {
|
||||
const types = items.map((d) => d.type).join(",")
|
||||
@@ -233,6 +287,12 @@ export namespace IngestQueue {
|
||||
|
||||
if (!response) {
|
||||
// Network failures are assumed transient; retry with backoff and a small budget.
|
||||
// Shutdown: one attempt only — log and drop so the process can exit.
|
||||
if (shutting) {
|
||||
options.log.error("share sync failed", { sessionId, error: "network", shutdown: true })
|
||||
return
|
||||
}
|
||||
|
||||
const count = (retry.get(sessionId)?.count ?? 0) + 1
|
||||
if (count > 6) {
|
||||
options.log.error("share sync failed", { sessionId, error: "retry budget exceeded" })
|
||||
@@ -243,7 +303,7 @@ export namespace IngestQueue {
|
||||
const delay = backoff(count)
|
||||
retry.set(sessionId, { count, until: now() + delay })
|
||||
options.log.error("share sync failed", { sessionId, error: "network", attempt: count, retryInMs: delay })
|
||||
enqueue(sessionId, items, "fill", now() + delay)
|
||||
requeue(sessionId, items, delay)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -276,6 +336,16 @@ export namespace IngestQueue {
|
||||
return
|
||||
}
|
||||
|
||||
if (shutting) {
|
||||
options.log.error("share sync failed", {
|
||||
sessionId,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
shutdown: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const current = retry.get(sessionId)
|
||||
const count = (current?.count ?? 0) + 1
|
||||
if (count > 6) {
|
||||
@@ -293,7 +363,7 @@ export namespace IngestQueue {
|
||||
attempt: count,
|
||||
retryInMs: delay,
|
||||
})
|
||||
enqueue(sessionId, items, "fill", now() + delay)
|
||||
requeue(sessionId, items, delay)
|
||||
} catch (error) {
|
||||
options.log.error("share sync failed", { sessionId, error })
|
||||
}
|
||||
@@ -305,6 +375,7 @@ export namespace IngestQueue {
|
||||
// - Otherwise, merge into the pending queue entry.
|
||||
// The next flush is scheduled ~1s after the first queued event (throttled), but never earlier
|
||||
// than the current backoff window (if retries are active).
|
||||
// - A batch containing session_close is terminal: flush ASAP (respecting backoff only).
|
||||
const client = await options.getClient()
|
||||
if (!client) return
|
||||
|
||||
@@ -313,15 +384,54 @@ export namespace IngestQueue {
|
||||
options.log.info("ingest sync", { sessionId, types })
|
||||
}
|
||||
|
||||
const terminal = data.some((item) => item.type === "session_close")
|
||||
const until = retry.get(sessionId)?.until ?? 0
|
||||
const base = queue.get(sessionId)?.due ?? now() + 1000
|
||||
// Terminal batches do not inherit the open debounce window — only backoff.
|
||||
const base = terminal ? now() : (queue.get(sessionId)?.due ?? now() + 1000)
|
||||
const due = Math.max(base, until)
|
||||
enqueue(sessionId, data, "overwrite", due)
|
||||
enqueue(sessionId, data, "overwrite", due, terminal)
|
||||
}
|
||||
|
||||
async function drain(bound = 3_000) {
|
||||
// Shutdown drain: one bounded attempt per pending session, join in-flight flushes, no re-enqueue.
|
||||
shutting = true
|
||||
const deadline = now() + bound
|
||||
|
||||
for (const sessionId of Array.from(queue.keys())) {
|
||||
void flush(sessionId)
|
||||
}
|
||||
|
||||
while (queue.size > 0 || inflight.size > 0) {
|
||||
for (const sessionId of Array.from(queue.keys())) {
|
||||
void flush(sessionId)
|
||||
}
|
||||
|
||||
if (now() >= deadline) {
|
||||
options.log.error("ingest drain timed out", {
|
||||
queue: queue.size,
|
||||
inflight: inflight.size,
|
||||
bound,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (inflight.size === 0) continue
|
||||
|
||||
const pending = Array.from(inflight)
|
||||
const left = Math.max(0, deadline - now())
|
||||
let timer: Timer | undefined
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = set(() => resolve(), left)
|
||||
})
|
||||
await Promise.race([Promise.allSettled(pending), timeout])
|
||||
if (timer !== undefined) clear(timer)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sync,
|
||||
flush,
|
||||
drain,
|
||||
} as const
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// kilocode_change - new file
|
||||
// Shared derivation for the spawn-capable instance advertisement payload.
|
||||
// Used by both `kilo remote` (explicit CLI) and `enableRemote()` (covers `/remote`
|
||||
// and KILO_REMOTE / remote_control auto-enable) so all enable paths advertise
|
||||
// identically.
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
|
||||
|
||||
function truncate(value: string, max: number) {
|
||||
return value.length > max ? value.slice(0, max) : value
|
||||
}
|
||||
|
||||
export function buildInstanceAdvertisement(directory: string): RemoteProtocol.InstanceAdvertisement {
|
||||
return {
|
||||
name: truncate(os.hostname(), 64),
|
||||
projectName: truncate(path.basename(directory) || directory, 64),
|
||||
version: truncate(InstallationVersion, 32),
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Auth } from "@/auth"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { IngestQueue } from "@/kilo-sessions/ingest-queue"
|
||||
import { IngestDrain } from "@/kilo-sessions/ingest-drain"
|
||||
import { clearInFlightCache, withInFlightCache } from "@/kilo-sessions/inflight-cache"
|
||||
import type * as SDK from "@kilocode/sdk/v2"
|
||||
import z from "zod"
|
||||
@@ -26,6 +27,7 @@ import simpleGit from "simple-git"
|
||||
import { RemoteWS } from "@/kilo-sessions/remote-ws"
|
||||
import { RemoteSender } from "@/kilo-sessions/remote-sender"
|
||||
import { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
|
||||
import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement"
|
||||
import { AttachedState } from "@/kilo-sessions/attached-state"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { Telemetry } from "@kilocode/kilo-telemetry"
|
||||
@@ -221,6 +223,18 @@ export namespace KiloSessions {
|
||||
},
|
||||
})
|
||||
|
||||
// Process-level once-guard: overlapping shutdown paths must not double-POST.
|
||||
// Do not call from per-directory instance finalizers — wrong granularity.
|
||||
// Never-reject: serve/worker await this unguarded before dispose/stop.
|
||||
const drainIngest = IngestDrain.create(
|
||||
() => ingest.drain(),
|
||||
(err) => log.warn("ingest drain failed", { err }),
|
||||
)
|
||||
|
||||
export async function drainIngestForShutdown() {
|
||||
await drainIngest()
|
||||
}
|
||||
|
||||
const remoteEnabled = process.env["KILO_REMOTE"] === "1"
|
||||
let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined
|
||||
let enabling: Promise<void> | undefined
|
||||
@@ -246,7 +260,25 @@ export namespace KiloSessions {
|
||||
const statusSyncs = new Map<string, { running: boolean; dirty: boolean }>()
|
||||
const STATUS_TIMEOUT_MS = 3_000
|
||||
|
||||
async function deriveStatus(sessionID: string): Promise<"idle" | "busy" | "question" | "permission" | "retry"> {
|
||||
// Shared attention/status resolution for ingest sync and the remote heartbeat.
|
||||
// Precedence: permission > question > SessionStatus (offline maps to retry).
|
||||
type DerivedSessionStatus = "idle" | "busy" | "question" | "permission" | "retry"
|
||||
|
||||
function resolveDerivedSessionStatus(input: {
|
||||
hasPermission: boolean
|
||||
hasQuestion: boolean
|
||||
statusType: SessionStatus.Info["type"] | undefined
|
||||
}): DerivedSessionStatus {
|
||||
if (input.hasPermission) return "permission"
|
||||
if (input.hasQuestion) return "question"
|
||||
if (input.statusType === "offline") return "retry"
|
||||
if (input.statusType === "busy" || input.statusType === "retry" || input.statusType === "idle") {
|
||||
return input.statusType
|
||||
}
|
||||
return "idle"
|
||||
}
|
||||
|
||||
async function deriveStatus(sessionID: string): Promise<DerivedSessionStatus> {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const permissions = (await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))).filter(
|
||||
(p) => p.sessionID === sessionID,
|
||||
@@ -259,8 +291,11 @@ export namespace KiloSessions {
|
||||
if (questions.length > 0) return "question"
|
||||
|
||||
const status = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(SessionID.make(sessionID))))
|
||||
if (status.type === "offline") return "retry"
|
||||
return status.type
|
||||
return resolveDerivedSessionStatus({
|
||||
hasPermission: false,
|
||||
hasQuestion: false,
|
||||
statusType: status.type,
|
||||
})
|
||||
}
|
||||
|
||||
async function deriveAndSyncStatus(sessionID: string) {
|
||||
@@ -456,9 +491,22 @@ export namespace KiloSessions {
|
||||
|
||||
export const node = LayerNode.suspend(() => LayerNode.make(layer, [Bus.node, Config.node, Session.node]))
|
||||
|
||||
// kilocode_change - DEF-1: default advertisement for every successful
|
||||
// enableRemote() entry (covers `/remote` after auto-enable already connected).
|
||||
// No-op when an advertisement is already set — must not re-set or fire an
|
||||
// extra heartbeat. Explicit setInstanceAdvertisement keeps replace semantics.
|
||||
function ensureDefaultInstanceAdvertisement() {
|
||||
if (instanceAdvertisement) return
|
||||
setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory))
|
||||
}
|
||||
|
||||
export async function enableRemote() {
|
||||
if (remote) return
|
||||
// ingestDisabled must not advertise. Every other successful entry — including
|
||||
// already-connected and coalescing early returns — must ensure advertisement
|
||||
// before returning, otherwise `/remote` after auto-enable never registers.
|
||||
if (ingestDisabled) return
|
||||
ensureDefaultInstanceAdvertisement()
|
||||
if (remote) return
|
||||
if (enabling) return enabling
|
||||
const seq = ++remoteSeq
|
||||
void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: true, connected: false })
|
||||
@@ -496,8 +544,16 @@ export namespace KiloSessions {
|
||||
branch().catch(() => undefined),
|
||||
])
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list()))
|
||||
// Batch SessionStatus + attention lists once per heartbeat (not per session).
|
||||
// Permission/Question list() feeds the same precedence as deriveStatus().
|
||||
const [statusMap, permissions, questions] = await Promise.all([
|
||||
AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())),
|
||||
AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())),
|
||||
AppRuntime.runPromise(Question.Service.use((svc) => svc.list())),
|
||||
])
|
||||
const statuses: Record<string, SessionStatus.Info> = Object.fromEntries(statusMap)
|
||||
const permissionSessions = new Set(permissions.map((p) => p.sessionID as string))
|
||||
const questionSessions = new Set(questions.map((q) => q.sessionID as string))
|
||||
// Advertise both presence-owned and pending-created ids so the relay learns about new
|
||||
// sessions before the next periodic heartbeat and the create_session response can be sent.
|
||||
const ids = new Set(Object.keys(statuses))
|
||||
@@ -509,7 +565,11 @@ export namespace KiloSessions {
|
||||
svc.get(SessionID.make(id)).pipe(
|
||||
Effect.map((session) => ({
|
||||
id,
|
||||
status: statuses[id]?.type ?? ("idle" as const),
|
||||
status: resolveDerivedSessionStatus({
|
||||
hasPermission: permissionSessions.has(id),
|
||||
hasQuestion: questionSessions.has(id),
|
||||
statusType: statuses[id]?.type,
|
||||
}),
|
||||
title: session.title,
|
||||
parentSessionId: session.parentID,
|
||||
gitUrl,
|
||||
|
||||
@@ -24,6 +24,24 @@ import { KiloLog } from "@/kilocode/log"
|
||||
|
||||
const log = Log.create({ service: "kilocode.cli" })
|
||||
|
||||
// Process-level ingest drain for non-TUI commands (`kilo run`, etc.).
|
||||
// KiloCli.shutdown() runs KiloShutdown before disposeAllInstances — preserve that order.
|
||||
// Registered at setup load time (not inside shutdown()) so the task is always present.
|
||||
// Dynamic import keeps setup.ts's own static import graph unchanged: consumers that load
|
||||
// setup.ts under partial module mocks (e.g. cli-shutdown tests whose @/auth mock omits
|
||||
// OAUTH_DUMMY_KEY) would otherwise fail to link the provider/plugin chain. Dynamic import
|
||||
// returns the same in-process module singleton, so the drained queue is the one that
|
||||
// received events. Task try/catch covers dynamic-import failure outside the shared drain
|
||||
// guard; the drain itself never rejects.
|
||||
KiloShutdown.register(async () => {
|
||||
try {
|
||||
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
|
||||
await KiloSessions.drainIngestForShutdown()
|
||||
} catch (err) {
|
||||
log.warn("ingest drain failed", { err })
|
||||
}
|
||||
})
|
||||
|
||||
// All Kilo-specific CLI customization lives here so the shared upstream entrypoint
|
||||
// (src/index.ts) only needs a handful of thin call-sites behind kilocode_change markers.
|
||||
// This keeps index.ts close to upstream and reduces merge conflicts on every sync.
|
||||
|
||||
@@ -616,15 +616,17 @@ function anthropicOpus47OrLater(apiId: string) {
|
||||
return major > 4 || (major === 4 && minor >= 7)
|
||||
}
|
||||
|
||||
// kilocode_change start - fable and sonnet-5 models are adaptive thinking models like opus-4.7/4.8
|
||||
// kilocode_change start - Claude 5+ models are adaptive thinking models like opus-4.7/4.8
|
||||
function anthropicClaude5(apiId: string) {
|
||||
const id = apiId.toLowerCase()
|
||||
return id.includes("fable") || /sonnet[.-]5/.test(id)
|
||||
if (id.includes("fable")) return true
|
||||
const version = /(?:opus|sonnet)[.-](\d+)(?:[.@-]|$)|claude-(\d+)(?:[.-]\d+)?-(?:opus|sonnet)(?:[.@-]|$)/.exec(id)
|
||||
return Number(version?.[1] ?? version?.[2]) >= 5
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
|
||||
// kilocode_change start - treat opus-4.8 and fable like opus-4.7
|
||||
// kilocode_change start - include Claude 5+ models
|
||||
if (anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId)) {
|
||||
return ["low", "medium", "high", "xhigh", "max"]
|
||||
}
|
||||
@@ -640,7 +642,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null {
|
||||
}
|
||||
|
||||
function anthropicOmitsThinking(apiId: string) {
|
||||
return anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId) // kilocode_change - include Kilo's fable/sonnet-5 aliases
|
||||
return anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId) // kilocode_change - include Kilo's Claude 5 aliases
|
||||
}
|
||||
|
||||
function googleThinkingLevelEfforts(apiId: string) {
|
||||
@@ -977,7 +979,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
if (adaptiveEfforts) {
|
||||
let efforts = [...adaptiveEfforts]
|
||||
if (model.providerID === "github-copilot") {
|
||||
// kilocode_change start - treat opus-4.8 and fable like opus-4.7
|
||||
// kilocode_change start - include Claude 5+ models
|
||||
if (
|
||||
model.api.id.includes("opus-4.7") ||
|
||||
model.api.id.includes("opus-4.8") ||
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("structured output")
|
||||
const result = yield* opencode.run("say hi", { format: "json" })
|
||||
const result = yield* opencode.run("say hi", { format: "json", extraArgs: ["--auto"] })
|
||||
opencode.expectExit(result, 0)
|
||||
|
||||
const events = opencode.parseJsonEvents(result.stdout)
|
||||
@@ -83,9 +83,25 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
expect(typeof evt.type).toBe("string")
|
||||
expect(typeof evt.sessionID).toBe("string")
|
||||
}
|
||||
// At least one `text` event should appear with the LLM's response.
|
||||
const text = events.find((e) => e.type === "text")
|
||||
expect(text).toBeDefined()
|
||||
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)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"--format json emits each completed tool once",
|
||||
({ 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 events = opencode.parseJsonEvents(result.stdout)
|
||||
expect(events.filter((event) => event.type === "tool_use")).toHaveLength(1)
|
||||
expect(events.filter((event) => event.type === "text")).toHaveLength(1)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
@@ -88,20 +88,14 @@ function retry(sessionID: string, attempt: number, message: string) {
|
||||
function assistant(id: string, sessionID = "session-1"): SdkEvent {
|
||||
return {
|
||||
id: `evt-${id}`,
|
||||
type: "sync",
|
||||
syncEvent: {
|
||||
type: "message.updated.1",
|
||||
id: `evt-${id}`,
|
||||
seq: 1,
|
||||
aggregateID: sessionID,
|
||||
data: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: assistantMessage({
|
||||
sessionID,
|
||||
info: assistantMessage({
|
||||
sessionID,
|
||||
id,
|
||||
parts: [],
|
||||
}).info,
|
||||
},
|
||||
id,
|
||||
parts: [],
|
||||
}).info,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -295,6 +289,18 @@ function textPart(id: string, messageID: string, text: string, sessionID = "sess
|
||||
}
|
||||
|
||||
function textUpdated(part: TextPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function syncTextUpdated(part: TextPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
type: "sync",
|
||||
@@ -338,17 +344,11 @@ function reasoningUpdated(part: ReasoningPart): SdkEvent {
|
||||
function toolUpdated(part: SessionToolPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
type: "sync",
|
||||
syncEvent: {
|
||||
type: "message.part.updated.1",
|
||||
id: `evt-${part.id}-updated`,
|
||||
seq: 1,
|
||||
aggregateID: part.sessionID,
|
||||
data: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 1,
|
||||
},
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -468,6 +468,34 @@ function sdk(
|
||||
}
|
||||
|
||||
describe("run stream transport", () => {
|
||||
test("ignores the sync copy of a native message event", async () => {
|
||||
const src = globalFeed()
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ globalStream: src.stream }),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const part = {
|
||||
...textPart("text-1", "msg-1", "Hello"),
|
||||
time: { start: 1, end: 2 },
|
||||
}
|
||||
|
||||
try {
|
||||
src.push(globalEvent(assistant("msg-1")))
|
||||
src.push(globalEvent(textUpdated(part)))
|
||||
src.push(globalEvent(syncTextUpdated(part)))
|
||||
|
||||
await waitFor(() => ui.commits.find((item) => item.kind === "assistant" && item.text === "Hello"))
|
||||
expect(ui.commits.filter((item) => item.kind === "assistant" && item.text === "Hello")).toHaveLength(1)
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not replay persisted main-session history during bootstrap by default", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { KiloShutdown } from "../../src/kilocode/cli/shutdown"
|
||||
|
||||
const calls: string[] = []
|
||||
const timeouts: Array<number | undefined> = []
|
||||
let err: unknown
|
||||
let drainErr: unknown
|
||||
let drainCalls = 0
|
||||
let exit: string | number | null | undefined
|
||||
|
||||
mock.module("@opencode-ai/core/global", () => ({
|
||||
@@ -67,6 +70,16 @@ mock.module("@/kilocode/session-export", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/kilo-sessions/kilo-sessions", () => ({
|
||||
KiloSessions: {
|
||||
async drainIngestForShutdown() {
|
||||
drainCalls += 1
|
||||
calls.push("drain")
|
||||
if (drainErr) throw drainErr
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/kilocode/help-command", () => ({
|
||||
createHelpCommand: () => ({ command: "help", handler() {} }),
|
||||
}))
|
||||
@@ -94,11 +107,34 @@ for (const path of [
|
||||
}))
|
||||
}
|
||||
|
||||
/** Same mock body as the kilo-sessions module mock used by setup.ts's drain task. */
|
||||
function registerDrain() {
|
||||
KiloShutdown.register(async () => {
|
||||
drainCalls += 1
|
||||
calls.push("drain")
|
||||
if (drainErr) throw drainErr
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a drain task for this test only. Clears any leftover registry entries first
|
||||
* (setup.ts's one-time module-scope registration, or a prior test) so assertions do not
|
||||
* depend on declaration order or on whether an earlier test already ran KiloShutdown.run().
|
||||
*/
|
||||
async function installDrain() {
|
||||
await KiloShutdown.run()
|
||||
calls.length = 0
|
||||
drainCalls = 0
|
||||
registerDrain()
|
||||
}
|
||||
|
||||
describe("KiloCli.shutdown", () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0
|
||||
timeouts.length = 0
|
||||
err = undefined
|
||||
drainErr = undefined
|
||||
drainCalls = 0
|
||||
exit = process.exitCode
|
||||
process.exitCode = undefined
|
||||
})
|
||||
@@ -107,26 +143,44 @@ describe("KiloCli.shutdown", () => {
|
||||
process.exitCode = exit
|
||||
})
|
||||
|
||||
test("keeps telemetry shutdown timeout best-effort and still disposes instances", async () => {
|
||||
err = "Timeout while shutting down PostHog. Some events may not have been sent."
|
||||
// Must stay first: setup registers the drain task once at import; KiloShutdown.run() clears it.
|
||||
// Only this test pins that one-time module-scope registration (and the drain-before-dispose
|
||||
// ordering it enables). Later tests call installDrain() so they do not rely on order.
|
||||
test("rejects drain without blocking dispose", async () => {
|
||||
drainErr = new Error("ingest drain failed")
|
||||
process.exitCode = 0
|
||||
const { KiloCli } = await import("../../src/kilocode/cli/setup")
|
||||
|
||||
await expect(KiloCli.shutdown()).resolves.toBeUndefined()
|
||||
|
||||
expect(drainCalls).toBe(1)
|
||||
expect(timeouts).toEqual([2000])
|
||||
expect(calls).toEqual(["track:0", "session", "telemetry", "dispose"])
|
||||
expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"])
|
||||
expect(process.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
test("keeps telemetry shutdown timeout best-effort and still disposes instances", async () => {
|
||||
err = "Timeout while shutting down PostHog. Some events may not have been sent."
|
||||
process.exitCode = 0
|
||||
const { KiloCli } = await import("../../src/kilocode/cli/setup")
|
||||
await installDrain()
|
||||
|
||||
await expect(KiloCli.shutdown()).resolves.toBeUndefined()
|
||||
|
||||
expect(timeouts).toEqual([2000])
|
||||
expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"])
|
||||
expect(process.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
test("preserves failing command exit status", async () => {
|
||||
process.exitCode = 1
|
||||
const { KiloCli } = await import("../../src/kilocode/cli/setup")
|
||||
await installDrain()
|
||||
|
||||
await KiloCli.shutdown()
|
||||
|
||||
expect(timeouts).toEqual([2000])
|
||||
expect(calls).toEqual(["track:1", "session", "telemetry", "dispose"])
|
||||
expect(calls).toEqual(["track:1", "session", "telemetry", "drain", "dispose"])
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
// a source-text/regex assertion on the handler's structure.
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildInstanceAdvertisement } from "../../../../src/cli/cmd/remote"
|
||||
// Shared helper lives in kilo-sessions; remote.ts re-exports for the CLI path.
|
||||
import { buildInstanceAdvertisement } from "../../../../src/kilo-sessions/instance-advertisement"
|
||||
|
||||
describe("RemoteCommand instance advertisement (K1 W1)", () => {
|
||||
test("buildInstanceAdvertisement resolves name/projectName/version from the directory and installation version", () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { spawn, type Exit } from "@opencode-ai/core/pty/driver"
|
||||
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
||||
import { tmpdir } from "../../../fixture/fixture"
|
||||
import {
|
||||
embeddedRemoteExitClient,
|
||||
@@ -41,6 +44,81 @@ describe("kilo tui thread", () => {
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test(
|
||||
"starts the TUI from a directory without OpenTUI dependencies",
|
||||
async () => {
|
||||
await using root = await tmpdir()
|
||||
const state = { text: "", exit: undefined as Exit | undefined }
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const stopped = Promise.withResolvers<void>()
|
||||
const proc = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=browser",
|
||||
`--preload=${fileURLToPath(import.meta.resolve("@opentui/solid/preload"))}`,
|
||||
path.resolve(import.meta.dir, "../../../../src/index.ts"),
|
||||
],
|
||||
{
|
||||
name: "xterm-256color",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: root.path,
|
||||
env: sanitizedProcessEnv({
|
||||
HOME: root.path,
|
||||
XDG_CONFIG_HOME: path.join(root.path, ".config"),
|
||||
XDG_DATA_HOME: path.join(root.path, ".local/share"),
|
||||
XDG_STATE_HOME: path.join(root.path, ".local/state"),
|
||||
XDG_CACHE_HOME: path.join(root.path, ".cache"),
|
||||
KILO_TEST_HOME: root.path,
|
||||
KILO_CONFIG_CONTENT: "{}",
|
||||
KILO_AUTH_CONTENT: "{}",
|
||||
KILO_DISABLE_PROJECT_CONFIG: "1",
|
||||
KILO_DISABLE_AUTOUPDATE: "1",
|
||||
KILO_DISABLE_MODELS_FETCH: "1",
|
||||
KILO_DISABLE_TERMINAL_TITLE: "0",
|
||||
KILO_DEV_CWD: "",
|
||||
KILO_PURE: "1",
|
||||
KILO_NO_DAEMON: "1",
|
||||
TERM: "xterm-256color",
|
||||
}),
|
||||
},
|
||||
)
|
||||
const data = proc.onData((chunk) => {
|
||||
state.text = (state.text + chunk).slice(-20_000)
|
||||
if (state.text.includes("TUI worker error")) {
|
||||
ready.reject(new Error(`TUI worker failed during startup:\n${state.text}`))
|
||||
return
|
||||
}
|
||||
// The title is emitted only after the worker-backed TUI reaches its rendered app.
|
||||
if (state.text.includes("Kilo CLI")) ready.resolve()
|
||||
})
|
||||
const exit = proc.onExit((event) => {
|
||||
state.exit = event
|
||||
stopped.resolve()
|
||||
ready.reject(
|
||||
new Error(
|
||||
`TUI exited before rendering (code ${event.exitCode}, signal ${event.signal ?? "none"}):\n${state.text}`,
|
||||
),
|
||||
)
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
ready.reject(new Error(`Timed out waiting for the TUI to render:\n${state.text}`))
|
||||
}, 30_000)
|
||||
|
||||
try {
|
||||
await ready.promise
|
||||
expect(state.text).toContain("Kilo CLI")
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
data.dispose()
|
||||
if (!state.exit) proc.kill()
|
||||
await stopped.promise
|
||||
exit.dispose()
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
)
|
||||
|
||||
test("ignores stale PWD after cwd is changed by a process wrapper", async () => {
|
||||
await using root = await tmpdir()
|
||||
const pkg = path.join(root.path, "packages", "opencode")
|
||||
|
||||
@@ -276,18 +276,17 @@ multi.live("isolates the process-wide listener by instance directory", () => {
|
||||
)
|
||||
})
|
||||
|
||||
// kilocode_change start - K1 W1: instance advertisement + per-session platform.
|
||||
// kilocode_change start - K1 W1 / DEF-1: instance advertisement + per-session platform.
|
||||
//
|
||||
// The race is the heart of this slice: `enableRemote` is idempotent/coalescing
|
||||
// and can be called from either the explicit `kilo remote` command OR from
|
||||
// bootstrap auto-enable (`KILO_REMOTE=1` / `remote_control` config). The
|
||||
// module-level `instanceAdvertisement` flag must make the next heartbeat
|
||||
// carry `instance` regardless of which caller won the race, and the setter
|
||||
// must trigger an out-of-band heartbeat when called against an existing
|
||||
// connection (so the cloud learns about the instance without waiting for
|
||||
// the next 10s timer tick).
|
||||
// `enableRemote` is idempotent/coalescing and is called from `/remote`, the
|
||||
// explicit `kilo remote` command, and bootstrap auto-enable (`KILO_REMOTE=1` /
|
||||
// `remote_control`). Every successful entry must ensure a default instance
|
||||
// advertisement (including the already-connected early return — the common
|
||||
// `/remote`-after-auto-enable path). Explicit `setInstanceAdvertisement`
|
||||
// keeps replace semantics and fires one out-of-band heartbeat per set when
|
||||
// connected; `enableRemote` with an ad already set is a no-op (no extra HB).
|
||||
|
||||
describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
|
||||
let heartbeatCalls = 0
|
||||
let outOfBand: Promise<void> | undefined
|
||||
|
||||
@@ -375,27 +374,51 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
return getSessions as () => Promise<RemoteProtocol.Heartbeat>
|
||||
}
|
||||
|
||||
test("flag is unset by default — heartbeats omit `instance`", async () => {
|
||||
test("enableRemote alone advertises the instance (covers /remote and auto-enable)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Contract: enableRemote entry with none set → derive and set.
|
||||
// No prior setInstanceAdvertisement (simulates /remote or auto-enable).
|
||||
await KiloSessions.enableRemote()
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.type).toBe("heartbeat")
|
||||
expect(payload.instance).toBeUndefined()
|
||||
expect(payload.instance).toBeDefined()
|
||||
expect(payload.instance!.projectName.length).toBeGreaterThan(0)
|
||||
expect(payload.instance!.name.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("setting the flag makes the next getSessions include `instance` (race: setter after enable)", async () => {
|
||||
test("enableRemote after already connected is a no-op for advertisement (no extra heartbeat)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Auto-enable connects first and advertises.
|
||||
await KiloSessions.enableRemote()
|
||||
const first = await capturedGetSessions()()
|
||||
expect(first.instance).toBeDefined()
|
||||
const before = heartbeatCalls
|
||||
// /remote calls enableRemote again; already-connected early return must
|
||||
// not re-set or fire an extra out-of-band heartbeat.
|
||||
await KiloSessions.enableRemote()
|
||||
expect(heartbeatCalls).toBe(before)
|
||||
const second = await capturedGetSessions()()
|
||||
expect(second.instance).toEqual(first.instance)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("explicit set after enable replaces the payload (kilo remote race)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
// Race: the explicit `kilo remote` command now sets the flag, after
|
||||
// `enableRemote` already coalesced with bootstrap auto-enable.
|
||||
// Explicit set keeps replace semantics even when enableRemote already
|
||||
// derived a default advertisement.
|
||||
KiloSessions.setInstanceAdvertisement({
|
||||
name: "mbp-igor",
|
||||
projectName: "cloud",
|
||||
@@ -414,11 +437,10 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const beforePayload = await capturedGetSessions()()
|
||||
expect(beforePayload.instance).toBeUndefined()
|
||||
// enableRemote already set a default ad; explicit set replaces and fires
|
||||
// exactly one out-of-band heartbeat.
|
||||
const beforeHeartbeatCalls = heartbeatCalls
|
||||
KiloSessions.setInstanceAdvertisement({ name: "h", projectName: "p" })
|
||||
// The setter fires one out-of-band heartbeat — wait for it.
|
||||
await outOfBand
|
||||
expect(heartbeatCalls).toBe(beforeHeartbeatCalls + 1)
|
||||
const afterPayload = await capturedGetSessions()()
|
||||
@@ -427,7 +449,7 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("setter is idempotent — second call replaces the payload and still fires one out-of-band heartbeat", async () => {
|
||||
test("setter replaces payload and fires one out-of-band heartbeat per call", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
@@ -445,6 +467,38 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("explicit set before enableRemote is preserved (no re-set on enable)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Contract: set before connect → flag stored; enable must not replace.
|
||||
KiloSessions.setInstanceAdvertisement({ name: "pre-set", projectName: "proj", version: "9.9.9" })
|
||||
await KiloSessions.enableRemote()
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.instance).toEqual({ name: "pre-set", projectName: "proj", version: "9.9.9" })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disableRemote does not clear the advertisement flag", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const before = await capturedGetSessions()()
|
||||
expect(before.instance).toBeDefined()
|
||||
KiloSessions.disableRemote()
|
||||
// Re-enable: ensureDefault must no-op (flag still set), and the new
|
||||
// connection's getSessions must still carry the same advertisement.
|
||||
await KiloSessions.enableRemote()
|
||||
const after = await capturedGetSessions()()
|
||||
expect(after.instance).toEqual(before.instance)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("per-session platform resolution matches meta() order — env var fallback", async () => {
|
||||
// The getSessions closure's platform field is computed as:
|
||||
// KiloSession.resolvePlatform(id) || process.env["KILO_PLATFORM"] || "cli"
|
||||
@@ -577,19 +631,22 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => {
|
||||
return chat.id
|
||||
}
|
||||
|
||||
for (const { label, status } of [
|
||||
{ label: "busy", status: { type: "busy" as const } },
|
||||
for (const { label, status, heartbeatStatus } of [
|
||||
{ label: "busy", status: { type: "busy" as const }, heartbeatStatus: "busy" },
|
||||
{
|
||||
label: "retry",
|
||||
status: { type: "retry" as const, attempt: 1, message: "retrying", next: 100 },
|
||||
heartbeatStatus: "retry",
|
||||
},
|
||||
{
|
||||
// SessionStatus.offline maps to heartbeat "retry" (same as deriveStatus).
|
||||
label: "offline",
|
||||
status: {
|
||||
type: "offline" as const,
|
||||
requestID: QuestionID.ascending(),
|
||||
message: "waiting for user",
|
||||
},
|
||||
heartbeatStatus: "retry",
|
||||
},
|
||||
]) {
|
||||
test(`clears ${label} SessionStatus so the detach heartbeat fence resolves`, async () => {
|
||||
@@ -607,7 +664,7 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => {
|
||||
|
||||
const getSessions = capturedGetSessions()
|
||||
const before = await getSessions()
|
||||
expect(before.sessions.some((s) => s.id === id && s.status === label)).toBe(true)
|
||||
expect(before.sessions.some((s) => s.id === id && s.status === heartbeatStatus)).toBe(true)
|
||||
|
||||
await KiloSessions.detachRemoteSession(id)
|
||||
|
||||
@@ -621,3 +678,308 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => {
|
||||
}, 30000)
|
||||
}
|
||||
})
|
||||
|
||||
// DEF-3 part 1: heartbeat per-session status must reflect pending
|
||||
// question/permission (same precedence as deriveStatus), with Permission and
|
||||
// Question list() called once per heartbeat — not once per session.
|
||||
describe("KiloSessions heartbeat attention status (DEF-3)", () => {
|
||||
beforeEach(() => {
|
||||
process.env["KILO_DISABLE_SESSION_INGEST"] = "0"
|
||||
delete process.env["KILO_SESSION_INGEST_URL"]
|
||||
process.env["KILO_API_KEY"] = "tok"
|
||||
reset("tok")
|
||||
KiloSessions.resetInstanceAdvertisementForTests()
|
||||
|
||||
spyOn(RemoteSender, "create").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
handle() {},
|
||||
dispose() {},
|
||||
}) as RemoteSender.Sender,
|
||||
)
|
||||
spyOn(RemoteWS, "connect").mockImplementation(
|
||||
(options) =>
|
||||
({
|
||||
connectionId: "test-conn",
|
||||
send() {},
|
||||
heartbeat: () => options.getSessions().then(() => undefined),
|
||||
close() {},
|
||||
get connected() {
|
||||
return true
|
||||
},
|
||||
}) as RemoteWS.Connection,
|
||||
)
|
||||
|
||||
clearInFlightCache("kilo-sessions:token")
|
||||
clearInFlightCache("kilo-sessions:token-valid:tok")
|
||||
|
||||
globalThis.fetch = mock(async (input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith("/api/user")) {
|
||||
return new Response(null, { status: 200 })
|
||||
}
|
||||
if (url.endsWith("/api/session")) {
|
||||
return Response.json({ id: "remote-test", ingestPath: "/api/ingest/test" })
|
||||
}
|
||||
throw new Error(`unexpected fetch in test: ${url}`)
|
||||
}) as unknown as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const pub = spyOn(Bus, "publish").mockResolvedValue(undefined as never)
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
KiloSessions.disableRemote()
|
||||
},
|
||||
})
|
||||
pub.mockRestore()
|
||||
mock.restore()
|
||||
delete process.env["KILO_DISABLE_SESSION_INGEST"]
|
||||
delete process.env["KILO_SESSION_INGEST_URL"]
|
||||
delete process.env["KILO_PLATFORM"]
|
||||
delete process.env["KILO_API_KEY"]
|
||||
reset("tok")
|
||||
})
|
||||
|
||||
function capturedGetSessions(): () => Promise<RemoteProtocol.Heartbeat> {
|
||||
const calls = (RemoteWS.connect as unknown as { mock: { calls: { 0: RemoteWS.Options }[] } }).mock.calls
|
||||
const getSessions = calls[0]?.[0].getSessions
|
||||
if (!getSessions) throw new Error("RemoteWS.connect was not called")
|
||||
return getSessions as () => Promise<RemoteProtocol.Heartbeat>
|
||||
}
|
||||
|
||||
async function setupSession() {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Session } = await import("@/session/session")
|
||||
const chat = await AppRuntime.runPromise(Session.Service.use((svc) => svc.create({})))
|
||||
return chat.id
|
||||
}
|
||||
|
||||
const questionPrompt = [
|
||||
{
|
||||
header: "Continue?",
|
||||
question: "Should I continue?",
|
||||
options: [
|
||||
{ label: "Yes", description: "Go" },
|
||||
{ label: "No", description: "Stop" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function waitForPermission(sessionID: string) {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Permission } = await import("@/permission")
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const pending = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
|
||||
if (pending.some((p) => p.sessionID === sessionID)) return
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
}
|
||||
throw new Error(`timed out waiting for permission on ${sessionID}`)
|
||||
}
|
||||
|
||||
async function waitForQuestion(sessionID: string) {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Question } = await import("@/question")
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||
if (pending.some((q) => q.sessionID === sessionID)) return
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
}
|
||||
throw new Error(`timed out waiting for question on ${sessionID}`)
|
||||
}
|
||||
|
||||
test("reports permission when a permission request is pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const id = await setupSession()
|
||||
await KiloSessions.attachRemoteSession(id)
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Permission } = await import("@/permission")
|
||||
const { PermissionV1 } = await import("@opencode-ai/core/v1/permission")
|
||||
const requestID = PermissionV1.ID.make("permission_hb_perm")
|
||||
|
||||
AppRuntime.runFork(
|
||||
Permission.Service.use((svc) =>
|
||||
svc.ask({
|
||||
id: requestID,
|
||||
sessionID: id,
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
await waitForPermission(id)
|
||||
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.sessions.some((s) => s.id === id && s.status === "permission")).toBe(true)
|
||||
|
||||
await AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply({ requestID, reply: "once" })))
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("reports question when a structured question is pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const id = await setupSession()
|
||||
await KiloSessions.attachRemoteSession(id)
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Question } = await import("@/question")
|
||||
|
||||
AppRuntime.runFork(Question.Service.use((svc) => svc.ask({ sessionID: id, questions: questionPrompt })))
|
||||
await waitForQuestion(id)
|
||||
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.sessions.some((s) => s.id === id && s.status === "question")).toBe(true)
|
||||
|
||||
const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||
const req = pending.find((q) => q.sessionID === id)
|
||||
expect(req).toBeDefined()
|
||||
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(req!.id)))
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("permission takes precedence over question", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const id = await setupSession()
|
||||
await KiloSessions.attachRemoteSession(id)
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Permission } = await import("@/permission")
|
||||
const { Question } = await import("@/question")
|
||||
const { PermissionV1 } = await import("@opencode-ai/core/v1/permission")
|
||||
const requestID = PermissionV1.ID.make("permission_hb_both")
|
||||
|
||||
AppRuntime.runFork(Question.Service.use((svc) => svc.ask({ sessionID: id, questions: questionPrompt })))
|
||||
AppRuntime.runFork(
|
||||
Permission.Service.use((svc) =>
|
||||
svc.ask({
|
||||
id: requestID,
|
||||
sessionID: id,
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
await waitForPermission(id)
|
||||
await waitForQuestion(id)
|
||||
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.sessions.some((s) => s.id === id && s.status === "permission")).toBe(true)
|
||||
|
||||
await AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply({ requestID, reply: "once" })))
|
||||
const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||
const req = pending.find((q) => q.sessionID === id)
|
||||
if (req) await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(req.id)))
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("idle/busy/retry unchanged when no attention is pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const idleId = await setupSession()
|
||||
const busyId = await setupSession()
|
||||
const retryId = await setupSession()
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.set(busyId, { type: "busy" })))
|
||||
await AppRuntime.runPromise(
|
||||
SessionStatus.Service.use((svc) =>
|
||||
svc.set(retryId, { type: "retry", attempt: 1, message: "retrying", next: 100 }),
|
||||
),
|
||||
)
|
||||
|
||||
await KiloSessions.attachRemoteSession(idleId)
|
||||
await KiloSessions.attachRemoteSession(busyId)
|
||||
await KiloSessions.attachRemoteSession(retryId)
|
||||
|
||||
const payload = await capturedGetSessions()()
|
||||
const byId = Object.fromEntries(payload.sessions.map((s) => [s.id, s.status]))
|
||||
expect(byId[idleId]).toBe("idle")
|
||||
expect(byId[busyId]).toBe("busy")
|
||||
expect(byId[retryId]).toBe("retry")
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("Permission and Question list() are called once per heartbeat across many sessions", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const id = await setupSession()
|
||||
await KiloSessions.attachRemoteSession(id)
|
||||
}
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Permission } = await import("@/permission")
|
||||
const { Question } = await import("@/question")
|
||||
|
||||
// list is readonly on the interface; cast to count calls in place.
|
||||
type ListBag = { list: () => unknown }
|
||||
const permSvc = (await AppRuntime.runPromise(
|
||||
Permission.Service.use((svc) => Effect.succeed(svc)),
|
||||
)) as unknown as ListBag
|
||||
const qSvc = (await AppRuntime.runPromise(
|
||||
Question.Service.use((svc) => Effect.succeed(svc)),
|
||||
)) as unknown as ListBag
|
||||
|
||||
let permissionListCalls = 0
|
||||
let questionListCalls = 0
|
||||
const origPermList = permSvc.list.bind(permSvc)
|
||||
const origQList = qSvc.list.bind(qSvc)
|
||||
permSvc.list = () => {
|
||||
permissionListCalls += 1
|
||||
return origPermList()
|
||||
}
|
||||
qSvc.list = () => {
|
||||
questionListCalls += 1
|
||||
return origQList()
|
||||
}
|
||||
|
||||
try {
|
||||
await capturedGetSessions()()
|
||||
// Once per heartbeat, not once per session (4 sessions attached).
|
||||
expect(permissionListCalls).toBe(1)
|
||||
expect(questionListCalls).toBe(1)
|
||||
|
||||
permissionListCalls = 0
|
||||
questionListCalls = 0
|
||||
await capturedGetSessions()()
|
||||
expect(permissionListCalls).toBe(1)
|
||||
expect(questionListCalls).toBe(1)
|
||||
} finally {
|
||||
permSvc.list = origPermList
|
||||
qSvc.list = origQList
|
||||
}
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { IngestDrain } from "../../../src/kilo-sessions/ingest-drain"
|
||||
|
||||
describe("IngestDrain once-guard", () => {
|
||||
test("overlapping invocations share a single underlying drain call", async () => {
|
||||
let calls = 0
|
||||
let resolveDrain!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
resolveDrain = resolve
|
||||
})
|
||||
|
||||
const drain = IngestDrain.create(async () => {
|
||||
calls += 1
|
||||
await gate
|
||||
})
|
||||
|
||||
const first = drain()
|
||||
const second = drain()
|
||||
expect(calls).toBe(1)
|
||||
|
||||
resolveDrain()
|
||||
await Promise.all([first, second])
|
||||
expect(calls).toBe(1)
|
||||
|
||||
await drain()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("sequential calls after completion still run only once", async () => {
|
||||
let calls = 0
|
||||
const drain = IngestDrain.create(async () => {
|
||||
calls += 1
|
||||
})
|
||||
|
||||
await drain()
|
||||
await drain()
|
||||
await drain()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("underlying run() rejection resolves, logs once, and does not retry", async () => {
|
||||
let calls = 0
|
||||
const errors: unknown[] = []
|
||||
const drain = IngestDrain.create(
|
||||
async () => {
|
||||
calls += 1
|
||||
throw new Error("boom")
|
||||
},
|
||||
(err) => {
|
||||
errors.push(err)
|
||||
},
|
||||
)
|
||||
|
||||
await expect(drain()).resolves.toBeUndefined()
|
||||
await expect(drain()).resolves.toBeUndefined()
|
||||
expect(calls).toBe(1)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toBeInstanceOf(Error)
|
||||
expect((errors[0] as Error).message).toBe("boom")
|
||||
})
|
||||
})
|
||||
@@ -467,4 +467,379 @@ describe("share ingest queue", () => {
|
||||
expect(payload.data.length).toBe(1)
|
||||
expect(payload.data[0].data.message).toBe("second")
|
||||
})
|
||||
|
||||
test("drain flushes every pending session and does not re-enqueue on failure", async () => {
|
||||
const sent: { sessionId: string; body: unknown }[] = []
|
||||
const sched = scheduler(() => clock.now)
|
||||
let fail = false
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: { error: () => {} },
|
||||
getShare: async (sessionId) => ({ ingestPath: `/ingest/${sessionId}` }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async (input, init) => {
|
||||
const url = String(input)
|
||||
const sessionId = url.includes("/s-a") ? "s-a" : "s-b"
|
||||
sent.push({ sessionId, body: JSON.parse((init?.body as string) ?? "{}") })
|
||||
if (fail) throw new Error("network")
|
||||
return new Response("{}", { status: 200 })
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await q.sync("s-a", [{ type: "session", data: { id: "s-a", v: 1 } as any }])
|
||||
await q.sync("s-b", [{ type: "session", data: { id: "s-b", v: 1 } as any }])
|
||||
expect(sched.size()).toBe(2)
|
||||
|
||||
await q.drain()
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(sent.length).toBe(2)
|
||||
expect(sent.map((s) => s.sessionId).sort()).toEqual(["s-a", "s-b"])
|
||||
expect(sched.size()).toBe(0)
|
||||
|
||||
// Failure path: drain POSTs once and does not re-enqueue for retry.
|
||||
fail = true
|
||||
clock.now = 5000
|
||||
await q.sync("s-c", [{ type: "session", data: { id: "s-c", v: 1 } as any }])
|
||||
await q.sync("s-d", [{ type: "session", data: { id: "s-d", v: 1 } as any }])
|
||||
expect(sched.size()).toBe(2)
|
||||
|
||||
const before = sent.length
|
||||
await q.drain()
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(sent.length).toBe(before + 2)
|
||||
expect(sched.size()).toBe(0)
|
||||
})
|
||||
|
||||
test("drain does not re-enqueue on retryable HTTP status under shutdown", async () => {
|
||||
const errors: Record<string, unknown>[] = []
|
||||
const sched = scheduler(() => clock.now)
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: {
|
||||
error: (_message, data) => {
|
||||
errors.push(data)
|
||||
},
|
||||
},
|
||||
getShare: async () => ({ ingestPath: "/ingest" }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async () => new Response("", { status: 429 }),
|
||||
}),
|
||||
})
|
||||
|
||||
await q.sync("s-429", [{ type: "session", data: { id: "s-429", v: 1 } as any }])
|
||||
expect(sched.size()).toBe(1)
|
||||
|
||||
await q.drain()
|
||||
await Bun.sleep(0)
|
||||
|
||||
// Shutdown path logs the retryable status and drops the item (no re-enqueue).
|
||||
expect(errors.some((e) => e.status === 429 && e.shutdown === true)).toBe(true)
|
||||
expect(sched.size()).toBe(0)
|
||||
})
|
||||
|
||||
test("drain POSTs using cached client/share when resolution fails at teardown", async () => {
|
||||
const urls: string[] = []
|
||||
const sched = scheduler(() => clock.now)
|
||||
let live = true
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: { error: () => {} },
|
||||
getShare: async () => {
|
||||
if (!live) return undefined
|
||||
return { ingestPath: "/ingest" }
|
||||
},
|
||||
getClient: async () => {
|
||||
if (!live) return undefined
|
||||
return {
|
||||
url: "https://ingest.test",
|
||||
fetch: async (input) => {
|
||||
urls.push(String(input))
|
||||
return new Response("{}", { status: 200 })
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Prime cache with a successful flush.
|
||||
await q.sync("s-cache", [{ type: "session", data: { id: "s-cache", v: 1 } as any }])
|
||||
clock.now = 1000
|
||||
sched.run()
|
||||
await Bun.sleep(0)
|
||||
expect(urls).toEqual(["https://ingest.test/ingest?v=2"])
|
||||
|
||||
// Queue a new item, then break resolution so drain must use the cache.
|
||||
clock.now = 2000
|
||||
await q.sync("s-cache", [{ type: "session", data: { id: "s-cache", v: 2 } as any }])
|
||||
live = false
|
||||
|
||||
await q.drain()
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(urls.length).toBe(2)
|
||||
expect(urls[1]).toBe("https://ingest.test/ingest?v=2")
|
||||
expect(sched.size()).toBe(0)
|
||||
})
|
||||
|
||||
test("drain waits for in-flight flush when queue map is empty", async () => {
|
||||
const sched = scheduler(() => clock.now)
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let started = false
|
||||
let finished = false
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: { error: () => {} },
|
||||
getShare: async () => ({ ingestPath: "/ingest" }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async () => {
|
||||
started = true
|
||||
await gate
|
||||
finished = true
|
||||
return new Response("{}", { status: 200 })
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await q.sync("s-inflight", [{ type: "session", data: { id: "s-inflight" } as any }])
|
||||
clock.now = 1000
|
||||
sched.run()
|
||||
await Bun.sleep(0)
|
||||
expect(started).toBe(true)
|
||||
expect(finished).toBe(false)
|
||||
expect(sched.size()).toBe(0)
|
||||
|
||||
const drained = q.drain()
|
||||
let drainDone = false
|
||||
void drained.then(() => {
|
||||
drainDone = true
|
||||
})
|
||||
|
||||
await Bun.sleep(0)
|
||||
expect(drainDone).toBe(false)
|
||||
expect(finished).toBe(false)
|
||||
|
||||
release()
|
||||
await drained
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(finished).toBe(true)
|
||||
expect(drainDone).toBe(true)
|
||||
})
|
||||
|
||||
test("drain suppresses re-enqueue when joined in-flight flush fails retryably", async () => {
|
||||
const sched = scheduler(() => clock.now)
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let started = false
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: { error: () => {} },
|
||||
getShare: async () => ({ ingestPath: "/ingest" }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async () => {
|
||||
started = true
|
||||
await gate
|
||||
throw new Error("network")
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await q.sync("s-join-fail", [{ type: "session", data: { id: "s-join-fail" } as any }])
|
||||
clock.now = 1000
|
||||
sched.run()
|
||||
await Bun.sleep(0)
|
||||
expect(started).toBe(true)
|
||||
expect(sched.size()).toBe(0)
|
||||
|
||||
const drained = q.drain()
|
||||
await Bun.sleep(0)
|
||||
|
||||
release()
|
||||
await drained
|
||||
await Bun.sleep(0)
|
||||
|
||||
// Shutdown suppresses re-enqueue; queue and timers must be empty.
|
||||
expect(sched.size()).toBe(0)
|
||||
})
|
||||
|
||||
test("drain resolves when the bound expires on a never-settling flush", async () => {
|
||||
const errors: { message: string; data: Record<string, unknown> }[] = []
|
||||
const sched = scheduler(() => clock.now)
|
||||
let started = false
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: {
|
||||
error: (message, data) => {
|
||||
errors.push({ message, data })
|
||||
},
|
||||
},
|
||||
getShare: async () => ({ ingestPath: "/ingest" }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async () => {
|
||||
started = true
|
||||
// Never settles — drain must exit via its bound timeout, not the fetch.
|
||||
return new Promise<Response>(() => {})
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await q.sync("s-bound", [{ type: "session", data: { id: "s-bound" } as any }])
|
||||
expect(sched.size()).toBe(1)
|
||||
|
||||
const drained = q.drain()
|
||||
let drainDone = false
|
||||
void drained.then(() => {
|
||||
drainDone = true
|
||||
})
|
||||
|
||||
// Drain flushes immediately; fetch hangs and schedules the bound timer.
|
||||
await Bun.sleep(0)
|
||||
expect(started).toBe(true)
|
||||
expect(drainDone).toBe(false)
|
||||
expect(sched.size()).toBe(1)
|
||||
expect(sched.nextAt()).toBe(3000)
|
||||
|
||||
// Advance past the 3s bound and fire the drain's internal timeout.
|
||||
clock.now = 3000
|
||||
sched.run()
|
||||
await drained
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(drainDone).toBe(true)
|
||||
expect(errors.some((e) => e.message === "ingest drain timed out")).toBe(true)
|
||||
// Bound expiry must not re-enqueue or leave a retry timer.
|
||||
expect(sched.size()).toBe(0)
|
||||
})
|
||||
|
||||
test("session_close into open debounce window reschedules flush earlier", async () => {
|
||||
const sent: unknown[] = []
|
||||
const sched = scheduler(() => clock.now)
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: { error: () => {} },
|
||||
getShare: async () => ({ ingestPath: "/ingest" }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async (_input, init) => {
|
||||
sent.push(JSON.parse((init?.body as string) ?? "{}"))
|
||||
return new Response("{}", { status: 200 })
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Open a normal ~1s debounce window with a part.
|
||||
await q.sync("s-term", [{ type: "part", data: { id: "p1" } as any }])
|
||||
expect(sched.nextAt()).toBe(1000)
|
||||
|
||||
// session_close must pull the flush forward to now (0 wait).
|
||||
clock.now = 200
|
||||
await q.sync("s-term", [{ type: "session_close", data: { reason: "completed" } }])
|
||||
expect(sched.nextAt()).toBe(200)
|
||||
|
||||
sched.run()
|
||||
await Bun.sleep(0)
|
||||
expect(sent.length).toBe(1)
|
||||
const payload = sent[0] as { data: { type: string }[] }
|
||||
expect(payload.data.map((d) => d.type).sort()).toEqual(["part", "session_close"])
|
||||
})
|
||||
|
||||
test("part/message-only batch still schedules at now + 1000", async () => {
|
||||
const sched = scheduler(() => clock.now)
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: { error: () => {} },
|
||||
getShare: async () => ({ ingestPath: "/ingest" }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async () => new Response("{}", { status: 200 }),
|
||||
}),
|
||||
})
|
||||
|
||||
clock.now = 500
|
||||
await q.sync("s-coalesce", [{ type: "part", data: { id: "p1" } as any }])
|
||||
expect(sched.nextAt()).toBe(1500)
|
||||
|
||||
clock.now = 800
|
||||
await q.sync("s-coalesce", [{ type: "message", data: { id: "m1" } as any }])
|
||||
// Later non-terminal sync must not move the flush earlier.
|
||||
expect(sched.nextAt()).toBe(1500)
|
||||
})
|
||||
|
||||
test("terminal batch respects active retry backoff", async () => {
|
||||
const sent: unknown[] = []
|
||||
const sched = scheduler(() => clock.now)
|
||||
let attempt = 0
|
||||
|
||||
const q = IngestQueue.create({
|
||||
now: () => clock.now,
|
||||
setTimeout: sched.setTimeout,
|
||||
clearTimeout: sched.clearTimeout,
|
||||
log: { error: () => {} },
|
||||
getShare: async () => ({ ingestPath: "/ingest" }),
|
||||
getClient: async () => ({
|
||||
url: "https://ingest.test",
|
||||
fetch: async (_input, init) => {
|
||||
attempt += 1
|
||||
if (attempt === 1) throw new Error("network")
|
||||
sent.push(JSON.parse((init?.body as string) ?? "{}"))
|
||||
return new Response("{}", { status: 200 })
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await q.sync("s-backoff", [{ type: "session", data: { id: "s-backoff", v: 1 } as any }])
|
||||
clock.now = 1000
|
||||
sched.run()
|
||||
await Bun.sleep(0)
|
||||
// Network fail → backoff 1000ms → due at 2000.
|
||||
expect(sched.nextAt()).toBe(2000)
|
||||
|
||||
clock.now = 1200
|
||||
await q.sync("s-backoff", [{ type: "session_close", data: { reason: "completed" } }])
|
||||
// Terminal must still respect retry.until (2000), not fire at now (1200).
|
||||
expect(sched.nextAt()).toBe(2000)
|
||||
|
||||
clock.now = 2000
|
||||
sched.run()
|
||||
await Bun.sleep(0)
|
||||
expect(sent.length).toBe(1)
|
||||
const payload = sent[0] as { data: { type: string }[] }
|
||||
expect(payload.data.map((d) => d.type).sort()).toEqual(["session", "session_close"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createWorkerShutdown } from "../../../src/cli/tui/worker-shutdown"
|
||||
|
||||
describe("createWorkerShutdown", () => {
|
||||
test("invokes drain before dispose and stopServer", async () => {
|
||||
const order: string[] = []
|
||||
let resolveDrain!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
resolveDrain = resolve
|
||||
})
|
||||
|
||||
const run = createWorkerShutdown({
|
||||
drain: async () => {
|
||||
order.push("drain-start")
|
||||
await gate
|
||||
order.push("drain-end")
|
||||
},
|
||||
dispose: async () => {
|
||||
order.push("dispose")
|
||||
},
|
||||
stopServer: async () => {
|
||||
order.push("stopServer")
|
||||
},
|
||||
})
|
||||
|
||||
const pending = run()
|
||||
// dispose must not start while drain is still in flight
|
||||
expect(order).toEqual(["drain-start"])
|
||||
|
||||
resolveDrain()
|
||||
await pending
|
||||
expect(order).toEqual(["drain-start", "drain-end", "dispose", "stopServer"])
|
||||
})
|
||||
|
||||
test("awaits drain fully before dispose even when drain is slow", async () => {
|
||||
const order: string[] = []
|
||||
const run = createWorkerShutdown({
|
||||
drain: async () => {
|
||||
order.push("drain")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
},
|
||||
dispose: async () => {
|
||||
order.push("dispose")
|
||||
},
|
||||
stopServer: async () => {
|
||||
order.push("stop")
|
||||
},
|
||||
})
|
||||
|
||||
await run()
|
||||
expect(order.indexOf("drain")).toBeLessThan(order.indexOf("dispose"))
|
||||
expect(order.indexOf("dispose")).toBeLessThan(order.indexOf("stop"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { TestCli } from "../../script/kilocode/test-cli"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../..")
|
||||
|
||||
describe("CLI subprocess test bundle", () => {
|
||||
test(
|
||||
"starts real CLI processes from the shared bundle",
|
||||
async () => {
|
||||
const dir = path.join(root, ".artifacts", `test-cli-regression-${process.pid}-${Date.now()}`)
|
||||
try {
|
||||
const entry = await (async () => {
|
||||
if (process.env[TestCli.ENV]) return process.env[TestCli.ENV]
|
||||
const script = [
|
||||
'import { TestCli } from "./script/kilocode/test-cli"',
|
||||
`console.log(await TestCli.build(process.cwd(), ${JSON.stringify(dir)}))`,
|
||||
].join(";")
|
||||
const proc = Bun.spawn([process.execPath, "-e", script], {
|
||||
cwd: root,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
})
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
if (code !== 0) throw new Error(`Test CLI build failed:\n${stderr}`)
|
||||
return stdout.trim()
|
||||
})()
|
||||
const runs = Array.from({ length: 4 }, () => {
|
||||
const proc = Bun.spawn([process.execPath, "run", entry, "--help"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
KILO_DB: ":memory:",
|
||||
KILO_CONFIG_CONTENT: "{}",
|
||||
KILO_AUTH_CONTENT: "{}",
|
||||
KILO_DISABLE_MODELS_FETCH: "1",
|
||||
KILO_DISABLE_PROJECT_CONFIG: "1",
|
||||
KILO_PURE: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
})
|
||||
return Promise.all([proc.exited, new Response(proc.stderr).text()])
|
||||
})
|
||||
const results = await Promise.all(runs)
|
||||
expect(results.map(([code]) => code)).toEqual([0, 0, 0, 0])
|
||||
for (const [, stderr] of results) expect(stderr).toContain("Commands:")
|
||||
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-test-cli-cwd-"))
|
||||
const serve = Bun.spawn([process.execPath, "run", entry, "serve", "--hostname", "127.0.0.1", "--port", "0"], {
|
||||
cwd: outside,
|
||||
env: {
|
||||
...process.env,
|
||||
KILO_DB: ":memory:",
|
||||
KILO_CONFIG_CONTENT: "{}",
|
||||
KILO_AUTH_CONTENT: "{}",
|
||||
KILO_DISABLE_MODELS_FETCH: "1",
|
||||
KILO_DISABLE_PROJECT_CONFIG: "1",
|
||||
KILO_PURE: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
})
|
||||
const stderr = new Response(serve.stderr).text()
|
||||
const output = await (async () => {
|
||||
const reader = serve.stdout.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let text = ""
|
||||
while (!text.includes("kilo server listening on")) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) break
|
||||
text += decoder.decode(chunk.value, { stream: true })
|
||||
}
|
||||
reader.releaseLock()
|
||||
return text
|
||||
})()
|
||||
if (serve.exitCode === null) serve.kill()
|
||||
const [code, err] = await Promise.all([serve.exited, stderr])
|
||||
expect(output, `stdout:\n${output}\nstderr:\n${err}`).toContain("kilo server listening on")
|
||||
expect(code, `stdout:\n${output}\nstderr:\n${err}`).not.toBe(1)
|
||||
} finally {
|
||||
if (!process.env[TestCli.ENV]) await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
})
|
||||
@@ -14,6 +14,7 @@ describe("test profiles", () => {
|
||||
expect(result.files.length).toBeGreaterThan(20)
|
||||
expect(result.files).toContain("pty/pty-shell.test.ts")
|
||||
expect(result.files).toContain("kilocode/cli/install-artifact.test.ts")
|
||||
expect(result.files).toContain("kilocode/cli/tui/thread.test.ts")
|
||||
expect(result.files).toContain("kilocode/sandbox/macos-confinement.test.ts")
|
||||
expect(result.files).toContain("kilocode/core-watcher.test.ts")
|
||||
expect(result.files).toContain("kilocode/background-process.test.ts")
|
||||
|
||||
@@ -11,12 +11,13 @@ function env(marker: string) {
|
||||
const vars: NodeJS.ProcessEnv = { ...process.env, KILO_TEST_RUNNER_PID_FILE: marker }
|
||||
delete vars.KILO_TEST_PROFILE
|
||||
delete vars.KILO_TEST_SHARD
|
||||
delete vars.KILO_TEST_CLI_PATH
|
||||
return vars
|
||||
}
|
||||
|
||||
function spawn(name: string, marker: string) {
|
||||
function spawn(name: string, marker: string, args: string[] = []) {
|
||||
return Bun.spawn(
|
||||
["bun", "run", "script/test-runner.ts", "--concurrency", "1", "--retries", "-1", `kilocode/${name}`],
|
||||
["bun", "run", "script/test-runner.ts", "--concurrency", "1", "--retries", "-1", ...args, `kilocode/${name}`],
|
||||
{
|
||||
cwd: root,
|
||||
env: env(marker),
|
||||
@@ -35,43 +36,47 @@ async function deadline<T>(promise: Promise<T>, timeout: number) {
|
||||
}
|
||||
|
||||
describe("test runner cleanup", () => {
|
||||
test("removes the temp environment after an abrupt child exit", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const name = `runner-abrupt-${process.pid}-${Date.now()}.test.ts`
|
||||
const file = path.join(import.meta.dir, name)
|
||||
const marker = path.join(tmp.path, "pid")
|
||||
const state = { pid: 0 }
|
||||
const src = [
|
||||
"const marker = process.env.KILO_TEST_RUNNER_PID_FILE",
|
||||
'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")',
|
||||
"await Bun.write(marker, String(process.pid))",
|
||||
"process.exit(1)",
|
||||
"",
|
||||
].join("\n")
|
||||
test(
|
||||
"removes the temp environment after an abrupt child exit",
|
||||
async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const name = `runner-abrupt-${process.pid}-${Date.now()}.test.ts`
|
||||
const file = path.join(import.meta.dir, name)
|
||||
const marker = path.join(tmp.path, "pid")
|
||||
const state = { pid: 0 }
|
||||
const src = [
|
||||
"const marker = process.env.KILO_TEST_RUNNER_PID_FILE",
|
||||
'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")',
|
||||
"await Bun.write(marker, String(process.pid))",
|
||||
"process.exit(1)",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
await fs.writeFile(file, src)
|
||||
const proc = spawn(name, marker)
|
||||
const stdout = new Response(proc.stdout).text()
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
await fs.writeFile(file, src)
|
||||
const proc = spawn(name, marker)
|
||||
const stdout = new Response(proc.stdout).text()
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
|
||||
try {
|
||||
const code = await deadline(proc.exited, 15_000)
|
||||
const output = await Promise.all([stdout, stderr])
|
||||
try {
|
||||
const code = await deadline(proc.exited, 15_000)
|
||||
const output = await Promise.all([stdout, stderr])
|
||||
|
||||
if (!(await Bun.file(marker).exists())) {
|
||||
throw new Error(`child did not record its pid\n${output[1] || output[0]}`)
|
||||
if (!(await Bun.file(marker).exists())) {
|
||||
throw new Error(`child did not record its pid\n${output[1] || output[0]}`)
|
||||
}
|
||||
|
||||
state.pid = Number(await fs.readFile(marker, "utf8"))
|
||||
expect(code).not.toBe(0)
|
||||
expect(await Bun.file(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)).exists()).toBe(false)
|
||||
} finally {
|
||||
if (proc.exitCode === null) proc.kill("SIGKILL")
|
||||
await proc.exited
|
||||
await fs.rm(file, { force: true })
|
||||
if (state.pid) await remove(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`))
|
||||
}
|
||||
|
||||
state.pid = Number(await fs.readFile(marker, "utf8"))
|
||||
expect(code).not.toBe(0)
|
||||
expect(await Bun.file(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)).exists()).toBe(false)
|
||||
} finally {
|
||||
if (proc.exitCode === null) proc.kill("SIGKILL")
|
||||
await proc.exited
|
||||
await fs.rm(file, { force: true })
|
||||
if (state.pid) await remove(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`))
|
||||
}
|
||||
})
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"removes active temp environments when the runner is terminated",
|
||||
@@ -106,7 +111,7 @@ describe("test runner cleanup", () => {
|
||||
state.pid = Number(await fs.readFile(marker, "utf8"))
|
||||
proc.kill("SIGTERM")
|
||||
|
||||
expect(await deadline(proc.exited, 10_000)).toBe(143)
|
||||
expect(await deadline(proc.exited, 10_000)).not.toBe(0)
|
||||
await Promise.all([stdout, stderr])
|
||||
expect(await Bun.file(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)).exists()).toBe(false)
|
||||
} finally {
|
||||
@@ -118,4 +123,96 @@ describe("test runner cleanup", () => {
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
|
||||
test("kills a timed-out test process tree", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const name = `runner-tree-${process.pid}-${Date.now()}.test.ts`
|
||||
const file = path.join(import.meta.dir, name)
|
||||
const marker = path.join(tmp.path, "pid")
|
||||
const src = [
|
||||
"const marker = process.env.KILO_TEST_RUNNER_PID_FILE",
|
||||
'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")',
|
||||
'const child = Bun.spawn([process.execPath, "-e", "await Bun.sleep(60000)"], { stdout: "inherit", stderr: "inherit" })',
|
||||
"await Bun.write(marker, String(child.pid))",
|
||||
"await Bun.sleep(60_000)",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
await fs.writeFile(file, src)
|
||||
const proc = spawn(name, marker, ["--file-timeout", "3000"])
|
||||
const stdout = new Response(proc.stdout).text()
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
|
||||
try {
|
||||
const code = await deadline(proc.exited, 15_000)
|
||||
const output = await Promise.all([stdout, stderr])
|
||||
expect(code, output[1] || output[0]).not.toBe(0)
|
||||
expect(output[0]).toContain("TIME")
|
||||
const pid = Number(await fs.readFile(marker, "utf8"))
|
||||
await deadline(
|
||||
(async () => {
|
||||
while (true) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
await Bun.sleep(25)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
})(),
|
||||
5_000,
|
||||
)
|
||||
} finally {
|
||||
if (proc.exitCode === null) proc.kill("SIGKILL")
|
||||
await proc.exited
|
||||
await fs.rm(file, { force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"bounds inherited output after the test process exits",
|
||||
async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const name = `runner-pipe-${process.pid}-${Date.now()}.test.ts`
|
||||
const file = path.join(import.meta.dir, name)
|
||||
const marker = path.join(tmp.path, "pid")
|
||||
const src = [
|
||||
"const marker = process.env.KILO_TEST_RUNNER_PID_FILE",
|
||||
'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")',
|
||||
'const child = Bun.spawn([process.execPath, "-e", "await Bun.sleep(60000)"], { stdout: "inherit", stderr: "inherit" })',
|
||||
"await Bun.write(marker, String(child.pid))",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
await fs.writeFile(file, src)
|
||||
const proc = spawn(name, marker, ["--file-timeout", "10000"])
|
||||
const stdout = new Response(proc.stdout).text()
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
|
||||
try {
|
||||
const code = await deadline(proc.exited, 15_000)
|
||||
const output = await Promise.all([stdout, stderr])
|
||||
expect(code, output[1] || output[0]).toBe(0)
|
||||
const pid = Number(await fs.readFile(marker, "utf8"))
|
||||
await deadline(
|
||||
(async () => {
|
||||
while (true) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
await Bun.sleep(25)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
})(),
|
||||
5_000,
|
||||
)
|
||||
} finally {
|
||||
if (proc.exitCode === null) proc.kill("SIGKILL")
|
||||
await proc.exited
|
||||
await fs.rm(file, { force: true })
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -207,6 +207,78 @@ describe("ProviderTransform.variants - Claude Opus 4.7 / 4.8", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("opus-5 returns adaptive thinking variants including xhigh (native anthropic)", () => {
|
||||
const model = mockModel({
|
||||
api: {
|
||||
id: "claude-opus-5",
|
||||
url: "https://api.anthropic.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
|
||||
expect(result.xhigh).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "xhigh",
|
||||
})
|
||||
})
|
||||
|
||||
test("opus-5 returns adaptive thinking variants via @ai-sdk/gateway", () => {
|
||||
const model = mockModel({
|
||||
id: "anthropic/claude-opus-5",
|
||||
api: {
|
||||
id: "anthropic/claude-opus-5",
|
||||
url: "https://gateway.ai",
|
||||
npm: "@ai-sdk/gateway",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
|
||||
})
|
||||
|
||||
test("opus-5 on bedrock returns adaptive reasoningConfig with xhigh", () => {
|
||||
const model = mockModel({
|
||||
api: {
|
||||
id: "anthropic.claude-opus-5",
|
||||
url: "https://bedrock.amazonaws.com",
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
|
||||
expect(result.xhigh).toEqual({
|
||||
reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", display: "summarized" },
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"claude-opus-5.3",
|
||||
"claude-opus-6",
|
||||
"claude-6-opus",
|
||||
"claude-opus-10",
|
||||
"claude-sonnet-5.3",
|
||||
"claude-sonnet-6",
|
||||
"claude-6-sonnet",
|
||||
"claude-sonnet-10",
|
||||
])(
|
||||
"%s is treated as an adaptive thinking model",
|
||||
(id) => {
|
||||
const model = mockModel({
|
||||
api: {
|
||||
id,
|
||||
url: "https://api.anthropic.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
|
||||
expect(result.xhigh).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "xhigh",
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test("sonnet-4.6 keeps original adaptive efforts without xhigh", () => {
|
||||
const model = mockModel({
|
||||
api: {
|
||||
|
||||
@@ -27,10 +27,15 @@ import path from "node:path"
|
||||
import { TestLLMServer } from "./llm-server"
|
||||
import { testProviderConfig } from "./test-provider"
|
||||
import { it } from "./effect"
|
||||
import { TestCli } from "../../script/kilocode/test-cli" // kilocode_change
|
||||
|
||||
const opencodeRoot = path.resolve(import.meta.dir, "../../")
|
||||
const cliEntry = path.join(opencodeRoot, "src/index.ts")
|
||||
const cliArgs = ["run", "--conditions=browser", "--preload=@opentui/solid/preload", cliEntry] // kilocode_change
|
||||
// kilocode_change start - reuse the runner's once-built CLI graph instead of transpiling it in every child
|
||||
const cliArgs = process.env[TestCli.ENV]
|
||||
? ["run", process.env[TestCli.ENV]]
|
||||
: ["run", "--conditions=browser", "--preload=@opentui/solid/preload", cliEntry]
|
||||
// kilocode_change end
|
||||
|
||||
export const testModelID = "test/test-model"
|
||||
|
||||
@@ -205,6 +210,7 @@ export function withCliFixture<A, E>(
|
||||
env: { ...env, ...opts?.env },
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
detached: false, // kilocode_change - keep test children in the runner's process lifecycle
|
||||
})
|
||||
// Pass timeout to appProc.run rather than wrapping with
|
||||
// Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so
|
||||
@@ -267,6 +273,7 @@ export function withCliFixture<A, E>(
|
||||
env: { ...process.env, ...env, ...opts?.env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true, // kilocode_change
|
||||
}),
|
||||
),
|
||||
(p) =>
|
||||
@@ -339,6 +346,7 @@ export function withCliFixture<A, E>(
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true, // kilocode_change
|
||||
}),
|
||||
),
|
||||
(p) =>
|
||||
@@ -456,5 +464,12 @@ export const cliIt = {
|
||||
name: string,
|
||||
body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
|
||||
opts?: number | TestOptions,
|
||||
) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts),
|
||||
) =>
|
||||
// kilocode_change start - Windows CI cannot reliably start nested CLI trees concurrently
|
||||
(process.platform === "win32" ? test : test.concurrent)(
|
||||
name,
|
||||
() => Effect.runPromise(Effect.scoped(withCliFixture(body))),
|
||||
opts,
|
||||
),
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ export function Prompt(props: PromptProps) {
|
||||
const keymap = useOpencodeKeymap()
|
||||
const agentShortcut = useCommandShortcut("agent.cycle")
|
||||
const paletteShortcut = useCommandShortcut("command.palette.show")
|
||||
const variantShortcut = useCommandShortcut("variant.cycle")
|
||||
const renderer = useRenderer()
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -1835,6 +1836,11 @@ export function Prompt(props: PromptProps) {
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Show when={local.model.variant.list().length > 0}>
|
||||
<text fg={theme.text}>
|
||||
{variantShortcut()} <span style={{ fg: theme.textMuted }}>variants</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={usage()}>
|
||||
{(item) => (
|
||||
|
||||
@@ -321,6 +321,17 @@ export const createMarkedParser = (props: { nativeParser?: NativeMarkdownParser
|
||||
},
|
||||
// kilocode_change end
|
||||
},
|
||||
// kilocode_change start: Marked accepts a tilde preceded by an opening
|
||||
// parenthesis as the closing delimiter. It is left-flanking there, so
|
||||
// preserve it literally instead of corrupting text such as "(~1 GB)".
|
||||
tokenizer: {
|
||||
del(src) {
|
||||
const match = this.rules.inline.del.exec(src)
|
||||
if (match?.[0].at(-2) === "(") return
|
||||
return false
|
||||
},
|
||||
},
|
||||
// kilocode_change end
|
||||
},
|
||||
// kilocode_change start: enable only double-dollar math.
|
||||
// Single $ is far more common as a currency symbol in agent responses
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createMarkedParser } from "../context/marked"
|
||||
|
||||
describe("Markdown strikethrough boundaries", () => {
|
||||
test.each(["(~a (~b", "~a (~b", "(~24 GB) and (~5.7 GB)", "(~/.config) and (~/.cache)"])(
|
||||
"preserves parenthesized tildes in %s",
|
||||
async (text) => {
|
||||
const parser = createMarkedParser({})
|
||||
const html = await Promise.resolve(parser.parse(text))
|
||||
|
||||
expect(html).not.toContain("<del>")
|
||||
expect(html).toContain(text)
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
["~removed~", "<del>removed</del>"],
|
||||
["~~removed~~", "<del>removed</del>"],
|
||||
["(~removed~)", "(<del>removed</del>)"],
|
||||
])("keeps valid strikethrough syntax in %s", async (text, expected) => {
|
||||
const parser = createMarkedParser({})
|
||||
const html = await Promise.resolve(parser.parse(text))
|
||||
|
||||
expect(html).toContain(expected)
|
||||
})
|
||||
})
|
||||
@@ -43,11 +43,16 @@ const testAllow: Record<string, { count: number; reason: string }> = {
|
||||
reason: "disk-backed instance integration test cleanup",
|
||||
},
|
||||
"kilocode/kilo-sessions.test.ts": {
|
||||
count: 4,
|
||||
count: 29,
|
||||
reason:
|
||||
"K1 W1: real integration test for SessionStatus→detach→heartbeat-fence; " +
|
||||
"the test creates a session and sets its status via the global AppRuntime, " +
|
||||
"then drives the module-level KiloSessions seams and verifies the fence.",
|
||||
"then drives the module-level KiloSessions seams and verifies the fence. " +
|
||||
"DEF-3 extends this with heartbeat attention-status coverage: the heartbeat " +
|
||||
"resolves pending question/permission from the global Question.Service and " +
|
||||
"Permission.Service, so a test can only assert it by raising and replying to " +
|
||||
"real requests through that same runtime. Scoped layers cannot express this — " +
|
||||
"the global-runtime coupling is exactly what is under test.",
|
||||
},
|
||||
"kilocode/session/platform-attribution.test.ts": { count: 2, reason: "existing runtime integration test" },
|
||||
"kilocode/session-prompt-queue.test.ts": { count: 6, reason: "prompt queue legacy instance bridge regression" },
|
||||
|
||||
@@ -45,6 +45,7 @@ const active = new Set([
|
||||
"nix-eval.yml",
|
||||
"nix-hashes.yml",
|
||||
"prepare-jetbrains-release.yml",
|
||||
"publish-jetbrains-bundled.yml",
|
||||
"publish-jetbrains.yml",
|
||||
"publish.yml",
|
||||
"smoke-test.yml",
|
||||
|
||||
Reference in New Issue
Block a user