mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge branch 'main' into imanolmaiztegui/kilo-opencode-v1.4.7
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix an infinite "busy" loop that could occur when a model kept reporting context overflow after every compaction. Each turn now caps compactions at three attempts and closes the turn with a visible context-overflow error instead of silently looping forever.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix the "Start new session" button on the plan follow-up prompt not switching the VS Code Agent Manager to the new session when handover generation is slow. The new session now opens immediately, shows the plan text right away, stays visibly busy while the handover summary is being prepared, and appends that summary once it finishes generating.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore explicit Submit behavior for single-choice question prompts in the VS Code extension so option clicks stay visible for review instead of immediately sending the answer.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix TUI freeze on huge-file diffs. Session-summary and file-view patches now use git directly instead of a JavaScript Myers implementation, so files of any size render a full diff without blocking the session.
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -172,7 +172,7 @@
|
||||
},
|
||||
"packages/kilo-docs": {
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@docsearch/css": "^4",
|
||||
"@docsearch/js": "^4",
|
||||
@@ -201,7 +201,7 @@
|
||||
},
|
||||
"packages/kilo-gateway": {
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
@@ -237,7 +237,7 @@
|
||||
},
|
||||
"packages/kilo-i18n": {
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -250,7 +250,7 @@
|
||||
},
|
||||
"packages/kilo-telemetry": {
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -270,7 +270,7 @@
|
||||
},
|
||||
"packages/kilo-ui": {
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/shared": "workspace:*",
|
||||
@@ -305,7 +305,7 @@
|
||||
},
|
||||
"packages/kilo-vscode": {
|
||||
"name": "kilo-code",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "@kilocode/cli",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"bin": {
|
||||
"kilo": "./bin/kilo",
|
||||
"kilocode": "./bin/kilo",
|
||||
@@ -513,7 +513,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -538,7 +538,7 @@
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3",
|
||||
},
|
||||
@@ -549,7 +549,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -588,7 +588,7 @@
|
||||
},
|
||||
"packages/storybook": {
|
||||
"name": "@opencode-ai/storybook",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -611,7 +611,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
|
||||
+1
-1
@@ -143,6 +143,6 @@
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch"
|
||||
},
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -30,6 +30,12 @@ Use the Markdoc image tag format:
|
||||
|
||||
Note that this site is served under kilo.ai/docs so the `/docs` prefix **must** be present in every image path.
|
||||
|
||||
#### Generated screenshots
|
||||
|
||||
When updating screenshots for active docs pages, prefer generated screenshot-test assets from `packages/kilo-docs/public/img/screenshot-tests/` and reference them as `/docs/img/screenshot-tests/...`. Only replace a hand-captured image when the generated screenshot matches the docs content closely. Do not replace screenshots in VSCode Legacy docs tabs or sections.
|
||||
|
||||
If a docs page references a generated VS Code visual-regression screenshot, record that usage in `packages/kilo-vscode/tests/visual-regression.spec.ts` by adding the story ID to the `DOCS` map. Keep `packages/kilo-vscode/tests/visual-regression.spec.mts` in sync while that file exists. If no matching generated screenshot exists, add or update a Storybook story in `packages/kilo-vscode/webview-ui/src/stories/` and let visual-regression CI generate the baseline.
|
||||
|
||||
Image attributes:
|
||||
|
||||
| Attribute | Type | Required | Description |
|
||||
|
||||
@@ -215,6 +215,15 @@ interface TopNavProps {
|
||||
showMobileMenuButton?: boolean
|
||||
}
|
||||
|
||||
function preview(url: string) {
|
||||
if (typeof window === "undefined" || !URL.canParse(url)) return url
|
||||
|
||||
const value = new URL(url)
|
||||
if (value.hostname !== "kilo.ai" || !value.pathname.startsWith("/docs")) return url
|
||||
|
||||
return `${window.location.origin}${value.pathname}${value.search}${value.hash}`
|
||||
}
|
||||
|
||||
export function TopNav({ onMobileMenuToggle, isMobileMenuOpen = false, showMobileMenuButton = true }: TopNavProps) {
|
||||
const router = useRouter()
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||
@@ -250,6 +259,12 @@ export function TopNav({ onMobileMenuToggle, isMobileMenuOpen = false, showMobil
|
||||
indexName: process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME || "docsearch",
|
||||
apiKey: process.env.NEXT_PUBLIC_ALGOLIA_API_KEY || "24b09689d5b4223813d9b8e48563c8f6",
|
||||
askAi: process.env.NEXT_PUBLIC_ALGOLIA_ASSISTANT_ID || "askAIDemo",
|
||||
transformItems(items) {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
url: preview(item.url),
|
||||
}))
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 3002",
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "Track and manage tasks with AI-generated todo lists"
|
||||
|
||||
**Why it matters**: Complex workflows have lots of moving parts. Without structure, it's easy to miss steps, duplicate work, or forget what comes next.
|
||||
|
||||
{% image src="/docs/img/task-todo-list/task-todo-list-1.png" alt="Task Todo List overview showing interactive checklist in Kilo Code" width="500" /%}
|
||||
{% image src="/docs/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png" alt="Task Todo List overview showing interactive checklist in Kilo Code" width="420" /%}
|
||||
|
||||
## How to trigger todo lists
|
||||
|
||||
@@ -53,7 +53,7 @@ Kilo updates the list when:
|
||||
**1. Task Header Summary**
|
||||
Quick progress overview with your next important item
|
||||
|
||||
{% image src="/docs/img/task-todo-list/task-header.png" alt="Task header summary showing todo list progress" width="500" /%}
|
||||
{% image src="/docs/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png" alt="Task header summary showing todo list progress" width="420" /%}
|
||||
|
||||
Click the task header summary to expand the full list inline and jump to the current item.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ platform: new
|
||||
|
||||
Workflows (also called **slash commands** in the new extension) automate repetitive tasks by defining step-by-step instructions for Kilo Code to execute.
|
||||
|
||||
{% image src="/docs/img/slash-commands/workflows.png" alt="Workflows tab in Kilo Code" width="600" caption="Workflows tab in Kilo Code" /%}
|
||||
{% image src="/docs/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-chromium-linux.png" alt="Workflows tab in Kilo Code" width="420" caption="Workflows tab in Kilo Code" /%}
|
||||
|
||||
## Creating Workflows
|
||||
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5ab73f8c0585e9f889c51073428aa0e7082d7aecdc3d5b0c459720c1b860c436
|
||||
size 4890
|
||||
oid sha256:d34153690f8aa4ef7e06e12385a184c151843f3ae75a9955619a6d7e88dc8c8b
|
||||
size 7981
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d4d7ac9d6322d2cf673a6353cb07cf9979ad55351769ba9d90eed386170f6c42
|
||||
size 40854
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||
|
||||
@@ -35,6 +35,8 @@ interface KiloRoutesDeps extends ImportDeps {
|
||||
Instance: ImportDeps["Instance"] & { disposeAll(): Promise<void> }
|
||||
}
|
||||
|
||||
const FIM_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* Create Kilo Gateway routes with OpenCode dependencies injected
|
||||
*
|
||||
@@ -342,25 +344,35 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
|
||||
[HEADER_FEATURE]: "autocomplete",
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: fimModel,
|
||||
prompt: prefix,
|
||||
suffix,
|
||||
max_tokens: fimMaxTokens,
|
||||
temperature: fimTemperature,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(FIM_TIMEOUT_MS)])
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return c.json({ error: `FIM request failed: ${response.status} ${errorText}` }, response.status as any)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
model: fimModel,
|
||||
prompt: prefix,
|
||||
suffix,
|
||||
max_tokens: fimMaxTokens,
|
||||
temperature: fimTemperature,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "TimeoutError")
|
||||
return c.json({ error: "FIM request timed out" }, 504 as any)
|
||||
if (signal.aborted) return c.json({ error: "FIM request canceled" }, 499 as any)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
return c.json({ error: `FIM request failed: ${response.status} ${text}` }, response.status as any)
|
||||
}
|
||||
|
||||
// Stream the response through
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -186,6 +186,12 @@ New webview features must use **`@kilocode/kilo-ui`** components instead of raw
|
||||
- **Prefer kilo-ui styles**: Always reuse existing kilo-ui CSS variables, tokens, and component styles instead of writing custom CSS. If a style doesn't exist in kilo-ui yet, add it there and reuse it rather than inlining or duplicating styles in the webview.
|
||||
- **Icons**: kilo-ui has 75+ custom SVG icons in [`packages/ui/src/components/icon.tsx`](../../packages/ui/src/components/icon.tsx). To list all available icon names: `node -e "const c=require('fs').readFileSync('../../packages/ui/src/components/icon.tsx','utf8');[...c.matchAll(/^\\s{2}[\"']?([\\w-]+)[\"']?:\\s*\x60/gm)].map(m=>m[1]).sort().forEach(n=>console.log(n))"`. Icon names use both hyphenated (`arrow-left`) and bare-word (`brain`, `console`, `providers`) keys.
|
||||
|
||||
## Docs Screenshot Stories
|
||||
|
||||
When adding or updating Storybook stories for screenshots used by docs, make the story content match the docs page closely before replacing the docs image. Do not replace screenshots from VSCode Legacy docs tabs or sections.
|
||||
|
||||
Generated screenshot baselines live under `packages/kilo-docs/public/img/screenshot-tests/` and are referenced from docs as `/docs/img/screenshot-tests/...`. If a generated VS Code visual-regression screenshot is used in docs, add the docs usage to the `DOCS` map in `tests/visual-regression.spec.ts` and keep `tests/visual-regression.spec.mts` in sync while that file exists.
|
||||
|
||||
## Debugging
|
||||
|
||||
- Extension logs: "Extension Host" output channel (not Debug Console)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# kilo-code
|
||||
|
||||
## 7.2.19
|
||||
|
||||
## 7.2.18
|
||||
|
||||
## 7.2.17
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "kilo-code",
|
||||
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
|
||||
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"icon": "assets/icons/logo-outline-black.png",
|
||||
"galleryBanner": {
|
||||
"color": "#FFFFFF",
|
||||
|
||||
@@ -47,7 +47,11 @@ for (const target of targets) {
|
||||
console.log(` ✅ Published ${target} to VS Code Marketplace`)
|
||||
|
||||
console.log(`\n📤 Publishing ${target} to Open VSX${prerelease ? " (pre-release)" : ""}...`)
|
||||
await $`npx ovsx publish ${flag} --pat ${process.env.OPENVSX_TOKEN} --packagePath ${vsixPath}`
|
||||
await retry(() => $`npx ovsx publish ${flag} --pat ${process.env.OPENVSX_TOKEN} --packagePath ${vsixPath}`, {
|
||||
attempts: 3,
|
||||
delay: 10_000,
|
||||
label: `ovsx publish ${target}`,
|
||||
})
|
||||
console.log(` ✅ Published ${target} to Open VSX`)
|
||||
}
|
||||
|
||||
@@ -58,3 +62,16 @@ if (Script.release) {
|
||||
}
|
||||
|
||||
console.log("\n✨ All targets published successfully!")
|
||||
|
||||
async function retry<T>(fn: () => Promise<T>, opts: { attempts: number; delay: number; label: string }): Promise<T> {
|
||||
for (let i = 1; i <= opts.attempts; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
if (i === opts.attempts) throw err
|
||||
console.warn(` ⚠️ ${opts.label} failed (attempt ${i}/${opts.attempts}), retrying in ${opts.delay / 1000}s...`)
|
||||
await new Promise((r) => setTimeout(r, opts.delay))
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* QuestionDock submission contract tests.
|
||||
*
|
||||
* Static analysis — reads QuestionDock source and locks the explicit-submit
|
||||
* behavior for single-question option picks.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const FILE = path.join(ROOT, "webview-ui/src/components/chat/QuestionDock.tsx")
|
||||
|
||||
function readFile(filePath: string): string {
|
||||
return fs.readFileSync(filePath, "utf-8")
|
||||
}
|
||||
|
||||
function extractBody(source: string, name: string): string {
|
||||
const marker = `const ${name} = `
|
||||
const start = source.indexOf(marker)
|
||||
if (start === -1) return ""
|
||||
const rest = source.slice(start + marker.length)
|
||||
const next = rest.search(/\n const /)
|
||||
return next === -1 ? rest : rest.slice(0, next)
|
||||
}
|
||||
|
||||
describe("QuestionDock explicit submit contract", () => {
|
||||
const source = readFile(FILE)
|
||||
const pick = extractBody(source, "pick")
|
||||
|
||||
it("keeps option clicks local instead of replying immediately", () => {
|
||||
expect(pick.length).toBeGreaterThan(0)
|
||||
expect(pick).not.toContain("reply([[answer]])")
|
||||
})
|
||||
|
||||
it("still advances multi-question single-select flows", () => {
|
||||
expect(pick).toContain('if (outcome.kind === "advance")')
|
||||
expect(pick).toContain('setStore("tab", store.tab + 1)')
|
||||
})
|
||||
|
||||
it("still syncs the optimistic agent selection on pick", () => {
|
||||
expect(pick).toContain("syncAgent(answers, kinds)")
|
||||
})
|
||||
|
||||
it("restores the optimistic agent before dismissing a question", () => {
|
||||
const reject = extractBody(source, "reject")
|
||||
expect(reject).toContain("if (prevAgent !== undefined)")
|
||||
expect(reject).toContain("session.selectAgent(prevAgent)")
|
||||
expect(reject).toContain("prevAgent = undefined")
|
||||
const reset = reject.indexOf("prevAgent = undefined")
|
||||
const sending = reject.indexOf('setStore("sending", true)')
|
||||
expect(reset).toBeGreaterThan(-1)
|
||||
expect(sending).toBeGreaterThan(-1)
|
||||
expect(reset, "reject should restore the agent before sending the dismissal").toBeLessThan(sending)
|
||||
})
|
||||
|
||||
it("keeps the footer Submit button wired to submit()", () => {
|
||||
expect(source).toContain('<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>')
|
||||
})
|
||||
})
|
||||
@@ -132,8 +132,8 @@ describe("resolveOptimisticQuestionAgent", () => {
|
||||
})
|
||||
|
||||
describe("pickOutcome", () => {
|
||||
it("submits immediately on a single-question single-select option pick", () => {
|
||||
expect(pickOutcome({ single: true, multi: false, custom: false })).toEqual({ kind: "submit" })
|
||||
it("keeps a single-question single-select option pick pending until explicit submit", () => {
|
||||
expect(pickOutcome({ single: true, multi: false, custom: false })).toEqual({ kind: "stay" })
|
||||
})
|
||||
|
||||
it("advances to the next tab on a multi-question single-select option pick", () => {
|
||||
|
||||
@@ -53,6 +53,27 @@ const SKIP = new Set<string>([
|
||||
"composite-webview--permission-dock-config-preloaded",
|
||||
])
|
||||
|
||||
const DOCS = new Map<string, string[]>([
|
||||
[
|
||||
"chat--task-header-with-todos",
|
||||
[
|
||||
"packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md:/docs/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png",
|
||||
],
|
||||
],
|
||||
[
|
||||
"composite-webview--todo-write-docs-overview",
|
||||
[
|
||||
"packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md:/docs/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png",
|
||||
],
|
||||
],
|
||||
[
|
||||
"settings--agent-behaviour-workflows",
|
||||
[
|
||||
"packages/kilo-docs/pages/customize/workflows.md:/docs/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-chromium-linux.png",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
// Generate one test() per story so Playwright's scheduler can distribute
|
||||
// them freely across workers — no manual sharding needed.
|
||||
// Skip fetching stories on macOS since test.skip() above already marks the file skipped.
|
||||
@@ -60,6 +81,10 @@ const stories = IS_DARWIN ? [] : (await fetchStories()).filter((s) => !SKIP.has(
|
||||
|
||||
for (const story of stories) {
|
||||
test(`${story.title} / ${story.name}`, async ({ page }) => {
|
||||
for (const ref of DOCS.get(story.id) ?? []) {
|
||||
test.info().annotations.push({ type: "docs", description: ref })
|
||||
}
|
||||
|
||||
// Narrow stories (IDs ending in "-200") use a 200px viewport
|
||||
// The "-200" suffix comes from the export name convention (e.g. Default200, WithThinking200)
|
||||
const narrow = story.id.endsWith("-200")
|
||||
@@ -74,6 +99,6 @@ for (const story of stories) {
|
||||
|
||||
const [component, variant] = story.id.split("--")
|
||||
const root = page.locator("#storybook-root")
|
||||
await expect(root).toHaveScreenshot(["visual-regression", component!, `${variant}.png`])
|
||||
await expect(root).toHaveScreenshot(["visual-regression", component!, `${variant}-chromium-linux.png`])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,6 +54,27 @@ const SKIP = new Set<string>([
|
||||
"composite-webview--permission-dock-config-preloaded",
|
||||
])
|
||||
|
||||
const DOCS = new Map<string, string[]>([
|
||||
[
|
||||
"chat--task-header-with-todos",
|
||||
[
|
||||
"packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md:/docs/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png",
|
||||
],
|
||||
],
|
||||
[
|
||||
"composite-webview--todo-write-docs-overview",
|
||||
[
|
||||
"packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md:/docs/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png",
|
||||
],
|
||||
],
|
||||
[
|
||||
"settings--agent-behaviour-workflows",
|
||||
[
|
||||
"packages/kilo-docs/pages/customize/workflows.md:/docs/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-chromium-linux.png",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
// Generate one test() per story so Playwright's scheduler can distribute
|
||||
// them freely across workers — no manual sharding needed.
|
||||
// Skip fetching stories on macOS since test.skip() above already marks the file skipped.
|
||||
@@ -61,6 +82,10 @@ const stories = IS_DARWIN ? [] : (await fetchStories()).filter((s) => !SKIP.has(
|
||||
|
||||
for (const story of stories) {
|
||||
test(`${story.title} / ${story.name}`, async ({ page }) => {
|
||||
for (const ref of DOCS.get(story.id) ?? []) {
|
||||
test.info().annotations.push({ type: "docs", description: ref })
|
||||
}
|
||||
|
||||
// Narrow stories (IDs ending in "-200") use a 200px viewport
|
||||
const narrow = story.id.endsWith("-200")
|
||||
await page.setViewportSize({ width: narrow ? 200 : 420, height: 720 })
|
||||
@@ -74,6 +99,6 @@ for (const story of stories) {
|
||||
|
||||
const [component, variant] = story.id.split("--")
|
||||
const root = page.locator("#storybook-root")
|
||||
await expect(root).toHaveScreenshot(["visual-regression", component!, `${variant!}.png`])
|
||||
await expect(root).toHaveScreenshot(["visual-regression", component!, `${variant!}-chromium-linux.png`])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,6 +102,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
|
||||
const reject = () => {
|
||||
if (store.sending) return
|
||||
if (prevAgent !== undefined) {
|
||||
session.selectAgent(prevAgent)
|
||||
prevAgent = undefined
|
||||
}
|
||||
setStore("sending", true)
|
||||
session.rejectQuestion(props.request.id)
|
||||
focusPrompt()
|
||||
@@ -145,17 +149,6 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
syncAgent(answers, kinds)
|
||||
|
||||
const outcome = pickOutcome({ single: single(), multi: multi(), custom })
|
||||
if (outcome.kind === "submit") {
|
||||
// Mirror TUI behaviour: a single-question single-select option pick submits immediately.
|
||||
// handleCustomSubmit covers the custom-input path via its own submit() call.
|
||||
//
|
||||
// NOTE: This auto-submit applies to every single-question single-select prompt, not only
|
||||
// the plan follow-up. If a future caller needs the user to review before submitting, set
|
||||
// `multiple: true` on the question (that path stays on the current tab and waits for the
|
||||
// footer Submit button).
|
||||
reply([[answer]])
|
||||
return
|
||||
}
|
||||
if (outcome.kind === "advance") {
|
||||
setStore("tab", store.tab + 1)
|
||||
}
|
||||
|
||||
@@ -26,14 +26,13 @@ export type PickOutcome = { kind: "submit" } | { kind: "advance" } | { kind: "st
|
||||
* Decide what should happen after a user picks an option in the question dock.
|
||||
*
|
||||
* - Multi-select prompts: the pick only toggles local state; no tab change, no submit.
|
||||
* - Single-question single-select, option pick: submit immediately (matches the TUI).
|
||||
* - Single-question single-select prompts: keep the pick local and wait for explicit Submit.
|
||||
* - Multi-question single-select, option pick: advance to the next tab.
|
||||
* - Custom-input path for a single-select is handled separately in handleCustomSubmit.
|
||||
*/
|
||||
export function pickOutcome(input: { single: boolean; multi: boolean; custom: boolean }): PickOutcome {
|
||||
if (input.multi) return { kind: "stay" }
|
||||
if (input.single && input.custom) return { kind: "stay" }
|
||||
if (input.single) return { kind: "submit" }
|
||||
if (input.single) return { kind: "stay" }
|
||||
return { kind: "advance" }
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const MOCK_PROVIDERS = {
|
||||
name: "Anthropic: Claude Sonnet 4.6",
|
||||
inputPrice: 0.003,
|
||||
outputPrice: 0.015,
|
||||
limit: { context: 200000, output: 8192 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SuggestBar } from "../components/chat/SuggestBar"
|
||||
import { MessageList } from "../components/chat/MessageList"
|
||||
import { SessionContext } from "../context/session"
|
||||
import { ServerContext } from "../context/server"
|
||||
import type { QuestionRequest, SuggestionRequest, TodoItem } from "../types/messages"
|
||||
import type { Message, Part, QuestionRequest, SuggestionRequest, TodoItem } from "../types/messages"
|
||||
|
||||
const SESSION_ID = "story-session-chat-001"
|
||||
|
||||
@@ -167,7 +167,7 @@ export const ChatViewWithPendingQuestionEmptyInput: Story = {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const QuestionDockSingle: Story = {
|
||||
name: "QuestionDock — single question",
|
||||
name: "QuestionDock — single question (explicit submit)",
|
||||
render: () => (
|
||||
<StoryProviders sessionID={SESSION_ID} questions={[singleQuestion]}>
|
||||
<div style={{ width: "100%" }}>
|
||||
@@ -449,10 +449,141 @@ export const MessageListSubagentToQueuedUserSpacing: Story = {
|
||||
// TaskHeader with todos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const headerNow = 1_700_000_000_000
|
||||
const headerUserID = "user-task-header-001"
|
||||
const headerAssistantID = "asst-task-header-001"
|
||||
const headerMessages: Message[] = [
|
||||
{
|
||||
id: headerUserID,
|
||||
sessionID: SESSION_ID,
|
||||
role: "user",
|
||||
content: "Can you use the update_todo_list tool to create a CLI interface implementation plan?",
|
||||
createdAt: new Date(headerNow - 12000).toISOString(),
|
||||
time: { created: headerNow - 12000 },
|
||||
},
|
||||
{
|
||||
id: headerAssistantID,
|
||||
sessionID: SESSION_ID,
|
||||
role: "assistant",
|
||||
parentID: headerUserID,
|
||||
content: "I'll track the CLI interface implementation with a todo list.",
|
||||
createdAt: new Date(headerNow - 10000).toISOString(),
|
||||
time: { created: headerNow - 10000 },
|
||||
modelID: "anthropic/claude-sonnet-4-6",
|
||||
providerID: "kilo",
|
||||
mode: "default",
|
||||
agent: "code",
|
||||
path: { cwd: "/project", root: "/project" },
|
||||
},
|
||||
]
|
||||
const headerParts: Record<string, Part[]> = {
|
||||
[headerAssistantID]: [
|
||||
{
|
||||
id: "part-header-read-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: headerAssistantID,
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "packages/opencode/src/cli/index.ts" },
|
||||
output: "export async function main() { /* existing CLI bootstrap */ }",
|
||||
title: "Read CLI entrypoint",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "part-header-text-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: headerAssistantID,
|
||||
type: "text",
|
||||
text: "I found the existing command registration and argument parsing flow.",
|
||||
},
|
||||
{
|
||||
id: "part-header-glob-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: headerAssistantID,
|
||||
type: "tool",
|
||||
tool: "glob",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { pattern: "packages/opencode/src/**/*.ts" },
|
||||
output:
|
||||
"packages/opencode/src/cli/index.ts\npackages/opencode/src/command/run.ts\npackages/opencode/src/config/config.ts",
|
||||
title: "Find CLI files",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "part-header-edit-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: headerAssistantID,
|
||||
type: "tool",
|
||||
tool: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "packages/opencode/src/cli/index.ts" },
|
||||
output: "Updated the command registry to expose the new interface hook.",
|
||||
title: "Update CLI registry",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "part-header-bash-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: headerAssistantID,
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "bun run check-types:webview", description: "Typecheck webview" },
|
||||
output: "Checked 1 project. No type errors found.",
|
||||
title: "Run typecheck",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "part-header-write-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: headerAssistantID,
|
||||
type: "tool",
|
||||
tool: "write",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "packages/opencode/src/cli/interface.ts" },
|
||||
output: "Created the CLI interface implementation scaffold.",
|
||||
title: "Create interface scaffold",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "part-header-text-002",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: headerAssistantID,
|
||||
type: "text",
|
||||
text: "Next I am wiring the implementation into the existing command path.",
|
||||
},
|
||||
{
|
||||
id: "part-header-bash-002",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: headerAssistantID,
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "bun test packages/opencode/test/cli.test.ts", description: "Run CLI tests" },
|
||||
title: "Run CLI tests",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const mockTodosInProgress: TodoItem[] = [
|
||||
{ id: "1", content: "Create a haiku about Jan", status: "completed" },
|
||||
{ id: "2", content: "Create a poem about Henk", status: "in_progress" },
|
||||
{ id: "3", content: "Write a limerick about the team", status: "pending" },
|
||||
{ id: "1", content: "Project setup and architecture backlog", status: "completed" },
|
||||
{ id: "2", content: "Configuration schema for target jobs", status: "completed" },
|
||||
{ id: "3", content: "Core scanning logic", status: "completed" },
|
||||
{ id: "4", content: "Build invocation and error handling", status: "completed" },
|
||||
{ id: "5", content: "CLI interface implementation", status: "in_progress" },
|
||||
{ id: "6", content: "Storage layer implementation", status: "pending" },
|
||||
{ id: "7", content: "Character profiles and prompt types", status: "pending" },
|
||||
{ id: "8", content: "Local tests and integration tests", status: "pending" },
|
||||
{ id: "9", content: "Migration guide", status: "pending" },
|
||||
{ id: "10", content: "Release validation", status: "pending" },
|
||||
]
|
||||
|
||||
const mockTodosAllDone: TodoItem[] = [
|
||||
@@ -465,19 +596,22 @@ export const TaskHeaderWithTodos: Story = {
|
||||
render: () => {
|
||||
const session = {
|
||||
...mockSessionValue({ id: SESSION_ID, status: "busy" }),
|
||||
messages: () => [{ id: "msg-001" }] as any[],
|
||||
messages: () => headerMessages,
|
||||
currentSession: () => ({
|
||||
id: SESSION_ID,
|
||||
title: "Writing poems about the team",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
title: "Task: Can you use the update_todo_list tool to create a CLI interface implementation?",
|
||||
createdAt: new Date(headerNow - 12000).toISOString(),
|
||||
updatedAt: new Date(headerNow).toISOString(),
|
||||
}),
|
||||
todos: () => mockTodosInProgress,
|
||||
getParts: (id: string) => headerParts[id] ?? [],
|
||||
contextUsage: () => ({ tokens: 34300, percentage: 17 }),
|
||||
costBreakdown: () => [{ label: "Session", cost: 0.64 }],
|
||||
}
|
||||
return (
|
||||
<StoryProviders sessionID={SESSION_ID} status="busy" noPadding>
|
||||
<SessionContext.Provider value={session as any}>
|
||||
<div style={{ width: "380px" }}>
|
||||
<div style={{ width: "100%" }}>
|
||||
<TaskHeader />
|
||||
</div>
|
||||
</SessionContext.Provider>
|
||||
|
||||
@@ -292,6 +292,52 @@ const todoWriteCompleted: ToolPart = {
|
||||
},
|
||||
}
|
||||
|
||||
const docsTodos = [
|
||||
{
|
||||
id: "1",
|
||||
content: "Project setup and core architecture (package.json, tsconfig, documentation, type definitions)",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
content: "Configuration system (scraper config, targets.json, validation utilities)",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
content: "Core scraping engine (browser manager, orchestrator, selector engine, content extractor)",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
content: "Utility modules (DOM utils, retry logic, URL handling, validation)",
|
||||
status: "completed",
|
||||
},
|
||||
{ id: "5", content: "CLI interface implementation", status: "in_progress" },
|
||||
{ id: "6", content: "Storage layer implementation (database and file storage)", status: "pending" },
|
||||
{ id: "7", content: "Chart extractors for specific chart types", status: "pending" },
|
||||
{ id: "8", content: "Logging and error handling systems", status: "pending" },
|
||||
{ id: "9", content: "Test suites (unit and integration tests)", status: "pending" },
|
||||
{ id: "10", content: "Main entry point and final integration", status: "pending" },
|
||||
]
|
||||
|
||||
const todoWriteDocsOverview: ToolPart = {
|
||||
id: "part-todo-docs-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: ASST_MSG_ID,
|
||||
type: "tool",
|
||||
callID: "call-todo-docs-001",
|
||||
tool: "todowrite",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { todos: docsTodos },
|
||||
output: "Updated 10 todos",
|
||||
title: "Todo List Updated",
|
||||
metadata: { todos: docsTodos },
|
||||
time: { start: now - 3000, end: now - 2800 },
|
||||
},
|
||||
}
|
||||
|
||||
const todoWritePermission: PermissionRequest = {
|
||||
id: "perm-todo-001",
|
||||
sessionID: SESSION_ID,
|
||||
@@ -609,6 +655,18 @@ export const TodoWriteCompleted: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const TodoWriteDocsOverview: Story = {
|
||||
name: "TodoWrite — docs overview",
|
||||
render: () => {
|
||||
const data = dataWith([todoWriteDocsOverview])
|
||||
return (
|
||||
<StoryProviders data={data} sessionID={SESSION_ID}>
|
||||
<AssistantMessage message={baseAssistantMessage} />
|
||||
</StoryProviders>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. Permission dock — edit tool with file patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @kilocode/cli
|
||||
|
||||
## 7.2.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`3b73cf4`](https://github.com/Kilo-Org/kilocode/commit/3b73cf474ee7bd81ac1cb4a0153906059f3a2d3a)]:
|
||||
- @kilocode/kilo-gateway@7.2.19
|
||||
- @kilocode/kilo-telemetry@7.2.19
|
||||
|
||||
## 7.2.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9300](https://github.com/Kilo-Org/kilocode/pull/9300) [`0d0dabe`](https://github.com/Kilo-Org/kilocode/commit/0d0dabe59838e48ec8633227c508531e2296dde9) - Fix the "Start new session" button on the plan follow-up prompt not switching the VS Code Agent Manager to the new session when handover generation is slow. The new session now opens immediately, shows the plan text right away, stays visibly busy while the handover summary is being prepared, and appends that summary once it finishes generating.
|
||||
|
||||
## 7.2.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"name": "@kilocode/cli",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Git } from "@/git"
|
||||
import { Effect, Layer, Context, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import { DiffFull } from "@/kilocode/snapshot/diff-full" // kilocode_change
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
@@ -559,6 +560,13 @@ export const layer = Layer.effect(
|
||||
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
|
||||
}
|
||||
if (diff.trim()) {
|
||||
// kilocode_change start — patch via git (DiffFull.file) instead of the JS Myers
|
||||
// implementation. Upstream structuredPatch branch below is kept as dead code so
|
||||
// our diff from upstream stays minimal and future merges don't conflict.
|
||||
const got = yield* DiffFull.file(gitText, file)
|
||||
if (got) return { type: "text" as const, content, patch: got.patch, diff: got.text }
|
||||
return { type: "text" as const, content }
|
||||
// kilocode_change end
|
||||
const original = yield* git.show(Instance.directory, "HEAD", file)
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
|
||||
@@ -165,4 +165,37 @@ export namespace KiloSessionPrompt {
|
||||
}
|
||||
return "completed"
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of compactions attempted within a single turn before we
|
||||
* surface an exhaustion error. Three is enough to cover a normal overflow
|
||||
* compaction plus a summary-self-overflow retry without spinning forever.
|
||||
*/
|
||||
export const MAX_COMPACTION_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* Guards a compaction attempt. When the attempt count has already reached
|
||||
* `MAX_COMPACTION_ATTEMPTS`, marks the close reason as `"error"`, attaches a
|
||||
* `ContextOverflowError` to the assistant message (if provided), and returns
|
||||
* `{ exhausted: true }` so callers can break out of the loop. Otherwise
|
||||
* returns `{ exhausted: false }`.
|
||||
*/
|
||||
export function guardCompactionAttempt(input: {
|
||||
sessionID: string
|
||||
attempts: number
|
||||
closeReasons: Map<string, KiloSession.CloseReason>
|
||||
message?: MessageV2.Assistant
|
||||
}) {
|
||||
if (input.attempts < MAX_COMPACTION_ATTEMPTS) return { exhausted: false as const }
|
||||
const error = new MessageV2.ContextOverflowError({
|
||||
message: `Compaction exhausted: context still exceeds model limits after ${MAX_COMPACTION_ATTEMPTS} attempts`,
|
||||
}).toObject()
|
||||
input.closeReasons.set(input.sessionID, "error")
|
||||
if (input.message) {
|
||||
// Preserve any pre-existing error/finish the caller already set; only fill in blanks.
|
||||
input.message.error ??= error
|
||||
input.message.finish ??= "error"
|
||||
}
|
||||
return { exhausted: true as const, error }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// kilocode_change - new file
|
||||
//
|
||||
// Patch generation. Runs `git diff --unified=INT_MAX` to produce
|
||||
// unified-diff text for a set of files, instead of the npm `diff` package's
|
||||
// JS Myers implementation. Myers is O(N*M) with full context, so on
|
||||
// huge-file diffs it can block the event loop for minutes (the TUI freeze
|
||||
// where ESC stopped working after a turn).
|
||||
//
|
||||
// Both helpers fail soft: on any git error they return an empty value so
|
||||
// callers emit an empty patch string. Additions/deletions come from
|
||||
// `git --numstat` and stay accurate.
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { parsePatch } from "diff"
|
||||
import type { StructuredPatch } from "diff"
|
||||
import { Log } from "../../util/log"
|
||||
|
||||
export namespace DiffFull {
|
||||
const log = Log.create({ service: "snapshot.diff-full" })
|
||||
|
||||
// INT_MAX — git clamps to this, effectively infinite context.
|
||||
const unified = "--unified=2147483647"
|
||||
|
||||
interface GitResult {
|
||||
readonly code: number
|
||||
readonly text: string
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `git diff --unified=INT_MAX` for a set of files between two refs and
|
||||
* return a `file → unified-diff text` map. Output format matches what the
|
||||
* `diff` package's `parsePatch` expects, so downstream clients continue to
|
||||
* work.
|
||||
*
|
||||
* `files` entries must use forward slashes (git's output uses `/` even on
|
||||
* Windows); paths with backslashes will silently miss the suffix match.
|
||||
*
|
||||
* Returns an empty map if `files` is empty or git fails. Callers emit an
|
||||
* empty patch string for any file missing from the map; numstat-derived
|
||||
* additions/deletions stay accurate.
|
||||
*/
|
||||
export const batch = Effect.fn("DiffFull.batch")(function* (
|
||||
git: (cmd: string[]) => Effect.Effect<GitResult>,
|
||||
from: string,
|
||||
to: string,
|
||||
files: string[],
|
||||
) {
|
||||
const map = new Map<string, string>()
|
||||
if (files.length === 0) return map
|
||||
|
||||
// Windows cmdline limit is ~8191 chars. 500 * avg-15-char filename ≈ 7500.
|
||||
const size = 500
|
||||
let failed = 0
|
||||
let stderr = ""
|
||||
for (let i = 0; i < files.length; i += size) {
|
||||
const chunk = files.slice(i, i + size)
|
||||
const result = yield* git([
|
||||
"diff",
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
"--no-renames",
|
||||
unified,
|
||||
from,
|
||||
to,
|
||||
"--",
|
||||
...chunk,
|
||||
])
|
||||
if (result.code !== 0) {
|
||||
failed += 1
|
||||
stderr = result.stderr || stderr
|
||||
continue
|
||||
}
|
||||
parseBatch(result.text, chunk, map)
|
||||
}
|
||||
if (failed) {
|
||||
log.info("git diff failed, emitting empty patches for affected files", {
|
||||
chunksFailed: failed,
|
||||
filesTotal: files.length,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/**
|
||||
* Generate a structured + unified diff for a single file in the working
|
||||
* tree vs HEAD using `git diff --ignore-all-space --unified=INT_MAX`.
|
||||
* Returns `null` if git produces no output (caller emits a content-only
|
||||
* response with no patch).
|
||||
*/
|
||||
export const file = Effect.fn("DiffFull.file")(function* (
|
||||
gitText: (args: string[]) => Effect.Effect<string>,
|
||||
file: string,
|
||||
) {
|
||||
const flags = ["-c", "core.fsmonitor=false", "diff", "--no-color", "--no-ext-diff", "--ignore-all-space", unified]
|
||||
const primary = yield* gitText([...flags, "--", file])
|
||||
const text = primary.trim() ? primary : yield* gitText([...flags, "--staged", "--", file])
|
||||
if (!text.trim()) return null
|
||||
const parsed = parsePatch(text)[0]
|
||||
if (!parsed) return null
|
||||
// Normalize paths to match what `structuredPatch(file, file, ...)` used to
|
||||
// produce — downstream UIs key off the bare filename, not `a/…` / `b/…`.
|
||||
const patch: StructuredPatch = {
|
||||
...parsed,
|
||||
oldFileName: file,
|
||||
newFileName: file,
|
||||
}
|
||||
return { patch, text }
|
||||
})
|
||||
|
||||
/**
|
||||
* Split a multi-file `git diff` output into one entry per file, keyed by
|
||||
* the input filename (not the path from the header — that can be quoted).
|
||||
* Silently drops sections whose header does not match any entry in `chunk`.
|
||||
*/
|
||||
function parseBatch(text: string, chunk: string[], map: Map<string, string>) {
|
||||
// Longest-first so `lib/a.txt` beats `a.txt` on suffix matches.
|
||||
const ordered = chunk.slice().sort((a, b) => b.length - a.length)
|
||||
// With `--no-renames` the header is always `diff --git a/PATH b/PATH` with
|
||||
// both PATHs identical, so we can confirm both halves to avoid false
|
||||
// positives where PATH happens to also appear as a substring earlier in
|
||||
// the line (e.g. a filename containing ` b/`).
|
||||
const match = (header: string) => {
|
||||
for (const f of ordered) {
|
||||
if (header.endsWith(" b/" + f) && header.includes(" a/" + f + " ")) return f
|
||||
if (header.endsWith(` "b/${f}"`) && header.includes(` "a/${f}" `)) return f
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let current: string | null = null
|
||||
let buffer: string[] = []
|
||||
const flush = () => {
|
||||
if (current !== null && buffer.length) map.set(current, buffer.join("\n"))
|
||||
current = null
|
||||
buffer = []
|
||||
}
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.startsWith("diff --git ")) {
|
||||
flush()
|
||||
current = match(line)
|
||||
if (current !== null) buffer.push(line)
|
||||
continue
|
||||
}
|
||||
if (current !== null) buffer.push(line)
|
||||
}
|
||||
flush()
|
||||
}
|
||||
}
|
||||
@@ -1340,6 +1340,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
// kilocode_change — cache environment details per turn (prompt caching)
|
||||
const envCache: KiloSessionPrompt.EnvCache = {}
|
||||
closeReasons.delete(sessionID) // kilocode_change
|
||||
let compactionAttempts = 0 // kilocode_change - cap compaction attempts per turn to avoid infinite loops
|
||||
const ctx = yield* InstanceState.context
|
||||
const slog = elog.with({ sessionID })
|
||||
let structured: unknown | undefined
|
||||
@@ -1421,7 +1422,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
auto: task.auto,
|
||||
overflow: task.overflow,
|
||||
})
|
||||
if (result === "stop") break
|
||||
// kilocode_change start - compaction.process only returns "stop" after
|
||||
// setting ContextOverflowError on the summary message; surface as turn error
|
||||
if (result === "stop") {
|
||||
closeReasons.set(sessionID, "error")
|
||||
break
|
||||
}
|
||||
// kilocode_change end
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1430,6 +1437,22 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
lastFinished.summary !== true &&
|
||||
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
|
||||
) {
|
||||
// kilocode_change start
|
||||
const guard = KiloSessionPrompt.guardCompactionAttempt({
|
||||
sessionID,
|
||||
attempts: compactionAttempts,
|
||||
closeReasons,
|
||||
message: lastFinished,
|
||||
})
|
||||
if (guard.exhausted) {
|
||||
// lastFinished is a prior turn's assistant — record exhaustion on the
|
||||
// message whose size tipped us past the compaction cap.
|
||||
yield* sessions.updateMessage(lastFinished)
|
||||
yield* bus.publish(Session.Event.Error, { sessionID, error: guard.error })
|
||||
break
|
||||
}
|
||||
compactionAttempts++
|
||||
// kilocode_change end
|
||||
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
|
||||
continue
|
||||
}
|
||||
@@ -1567,6 +1590,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
// kilocode_change end
|
||||
if (result === "compact") {
|
||||
// kilocode_change start
|
||||
const guard = KiloSessionPrompt.guardCompactionAttempt({
|
||||
sessionID,
|
||||
attempts: compactionAttempts,
|
||||
closeReasons,
|
||||
message: handle.message,
|
||||
})
|
||||
if (guard.exhausted) {
|
||||
yield* sessions.updateMessage(handle.message)
|
||||
yield* bus.publish(Session.Event.Error, { sessionID, error: guard.error })
|
||||
return "break" as const
|
||||
}
|
||||
compactionAttempts++
|
||||
// kilocode_change end
|
||||
yield* compaction.create({
|
||||
sessionID,
|
||||
agent: lastUser.agent,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Config } from "../config"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util"
|
||||
import { Flag } from "@/flag/flag" // kilocode_change
|
||||
import { DiffFull } from "../kilocode/snapshot/diff-full" // kilocode_change
|
||||
|
||||
export const Patch = z.object({
|
||||
hash: z.string(),
|
||||
@@ -714,6 +715,30 @@ export const layer: Layer.Layer<
|
||||
const patch = (file: string, before: string, after: string) =>
|
||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||
|
||||
// kilocode_change start — route patches through git (DiffFull.batch) instead of the
|
||||
// JS Myers implementation. Upstream Myers loop below is kept as dead code so our
|
||||
// diff from upstream stays minimal and future merges don't conflict.
|
||||
for (let i = 0; i < rows.length; i += step) {
|
||||
const run = rows.slice(i, i + step)
|
||||
const patches = yield* DiffFull.batch(
|
||||
(cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }),
|
||||
from,
|
||||
to,
|
||||
run.filter((r) => !r.binary).map((r) => r.file),
|
||||
)
|
||||
for (const row of run) {
|
||||
result.push({
|
||||
file: row.file,
|
||||
patch: row.binary ? "" : (patches.get(row.file) ?? ""),
|
||||
additions: row.additions,
|
||||
deletions: row.deletions,
|
||||
status: row.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
// kilocode_change end
|
||||
|
||||
for (let i = 0; i < rows.length; i += step) {
|
||||
const run = rows.slice(i, i + step)
|
||||
const text = yield* load(run)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// kilocode_change - new file
|
||||
//
|
||||
// Tests for the git-based diff generator that replaced the JS Myers path.
|
||||
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { DiffFull } from "../../src/kilocode/snapshot/diff-full"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
// Mirror the caller's git-wrapping in `snapshot/index.ts`: always disable
|
||||
// quotepath so non-ASCII filenames come through unescaped.
|
||||
const gitResult = (dir: string) => (args: string[]) =>
|
||||
Effect.promise(async () => {
|
||||
const p = Bun.spawn(["git", "-c", "core.quotepath=false", ...args], {
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [text, stderr] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
|
||||
const code = await p.exited
|
||||
return { code, text, stderr }
|
||||
})
|
||||
|
||||
const gitText = (dir: string) => (args: string[]) =>
|
||||
Effect.promise(async () => {
|
||||
const p = Bun.spawn(["git", "-c", "core.quotepath=false", ...args], {
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await p.exited
|
||||
return await new Response(p.stdout).text()
|
||||
})
|
||||
|
||||
const commit = async (dir: string, message: string) => {
|
||||
await $`git add -A`.cwd(dir).quiet()
|
||||
await $`git commit --no-gpg-sign -m ${message}`.cwd(dir).quiet()
|
||||
const head = await $`git rev-parse HEAD`.cwd(dir).quiet()
|
||||
return head.stdout.toString().trim()
|
||||
}
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
describe("DiffFull.batch", () => {
|
||||
it.live("produces one patch per modified file", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(async () => {
|
||||
await Filesystem.write(path.join(dir, "a.txt"), "alpha\nbeta\ngamma\n")
|
||||
await Filesystem.write(path.join(dir, "b.txt"), "one\ntwo\nthree\n")
|
||||
await Filesystem.write(path.join(dir, "c.txt"), "red\nblue\ngreen\n")
|
||||
})
|
||||
const from = yield* Effect.promise(() => commit(dir, "v1"))
|
||||
yield* Effect.promise(async () => {
|
||||
await Filesystem.write(path.join(dir, "a.txt"), "alpha\nBETA\ngamma\n")
|
||||
await Filesystem.write(path.join(dir, "b.txt"), "one\nTWO\nthree\n")
|
||||
await Filesystem.write(path.join(dir, "c.txt"), "red\nBLUE\ngreen\n")
|
||||
})
|
||||
const to = yield* Effect.promise(() => commit(dir, "v2"))
|
||||
|
||||
const result = yield* DiffFull.batch(gitResult(dir), from, to, ["a.txt", "b.txt", "c.txt"])
|
||||
expect(result.size).toBe(3)
|
||||
expect(result.get("a.txt")).toMatch(/^diff --git /)
|
||||
expect(result.get("a.txt")).toContain("-beta")
|
||||
expect(result.get("a.txt")).toContain("+BETA")
|
||||
expect(result.get("b.txt")).toContain("-two")
|
||||
expect(result.get("b.txt")).toContain("+TWO")
|
||||
expect(result.get("c.txt")).toContain("-blue")
|
||||
expect(result.get("c.txt")).toContain("+BLUE")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("tolerates paths with spaces and unicode", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const names = ["my file.txt", "héllo.txt", "a b c.md"]
|
||||
yield* Effect.promise(async () => {
|
||||
for (const name of names) {
|
||||
await Filesystem.write(path.join(dir, name), "before\n")
|
||||
}
|
||||
})
|
||||
const from = yield* Effect.promise(() => commit(dir, "v1"))
|
||||
yield* Effect.promise(async () => {
|
||||
for (const name of names) {
|
||||
await Filesystem.write(path.join(dir, name), "after\n")
|
||||
}
|
||||
})
|
||||
const to = yield* Effect.promise(() => commit(dir, "v2"))
|
||||
|
||||
const result = yield* DiffFull.batch(gitResult(dir), from, to, names)
|
||||
expect(result.size).toBe(names.length)
|
||||
for (const name of names) {
|
||||
const patch = result.get(name)
|
||||
expect(patch).toBeDefined()
|
||||
expect(patch).toContain("-before")
|
||||
expect(patch).toContain("+after")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns an empty map without spawning for an empty file list", () =>
|
||||
Effect.gen(function* () {
|
||||
let calls = 0
|
||||
const stub = (_: string[]) =>
|
||||
Effect.sync(() => {
|
||||
calls += 1
|
||||
return { code: 0, text: "", stderr: "" }
|
||||
})
|
||||
const result = yield* DiffFull.batch(stub, "from", "to", [])
|
||||
expect(result.size).toBe(0)
|
||||
expect(calls).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns an empty map on git failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(async () => {
|
||||
await Filesystem.write(path.join(dir, "a.txt"), "hi\n")
|
||||
})
|
||||
yield* Effect.promise(() => commit(dir, "v1"))
|
||||
const result = yield* DiffFull.batch(gitResult(dir), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "HEAD", [
|
||||
"a.txt",
|
||||
])
|
||||
expect(result.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"chunks 1200 files across three spawns and returns all entries",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const names = Array.from({ length: 1200 }, (_, i) => `f${i.toString().padStart(4, "0")}.txt`)
|
||||
yield* Effect.promise(async () => {
|
||||
await Promise.all(names.map((name) => Filesystem.write(path.join(dir, name), "before\n")))
|
||||
})
|
||||
const from = yield* Effect.promise(() => commit(dir, "v1"))
|
||||
yield* Effect.promise(async () => {
|
||||
await Promise.all(names.map((name) => Filesystem.write(path.join(dir, name), "after\n")))
|
||||
})
|
||||
const to = yield* Effect.promise(() => commit(dir, "v2"))
|
||||
|
||||
let calls = 0
|
||||
const counting = (cmd: string[]) => {
|
||||
calls += 1
|
||||
return gitResult(dir)(cmd)
|
||||
}
|
||||
const result = yield* DiffFull.batch(counting, from, to, names)
|
||||
expect(result.size).toBe(1200)
|
||||
expect(calls).toBe(3)
|
||||
expect(result.get("f0000.txt")).toContain("-before")
|
||||
expect(result.get("f1199.txt")).toContain("+after")
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
})
|
||||
|
||||
describe("DiffFull.file", () => {
|
||||
it.live("returns a structured + unified diff for a modified working-tree file", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(async () => {
|
||||
await Filesystem.write(path.join(dir, "foo.txt"), "line1\nline2\nline3\n")
|
||||
})
|
||||
yield* Effect.promise(() => commit(dir, "v1"))
|
||||
yield* Effect.promise(async () => {
|
||||
await Filesystem.write(path.join(dir, "foo.txt"), "line1\nCHANGED\nline3\n")
|
||||
})
|
||||
|
||||
const got = yield* DiffFull.file(gitText(dir), "foo.txt")
|
||||
expect(got).not.toBeNull()
|
||||
expect(got!.text).toMatch(/^diff --git /)
|
||||
expect(got!.text).toContain("-line2")
|
||||
expect(got!.text).toContain("+CHANGED")
|
||||
expect(got!.patch.oldFileName).toBe("foo.txt")
|
||||
expect(got!.patch.newFileName).toBe("foo.txt")
|
||||
expect(got!.patch.hunks.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns null for an unmodified file", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(async () => {
|
||||
await Filesystem.write(path.join(dir, "foo.txt"), "unchanged\n")
|
||||
})
|
||||
yield* Effect.promise(() => commit(dir, "v1"))
|
||||
|
||||
const got = yield* DiffFull.file(gitText(dir), "foo.txt")
|
||||
expect(got).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"handles a 15,000-line file in under 500 ms",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const before = Array.from({ length: 15_000 }, (_, i) => `v1_line_${i}`).join("\n") + "\n"
|
||||
const after = Array.from({ length: 15_000 }, (_, i) => `v2_line_${i}`).join("\n") + "\n"
|
||||
yield* Effect.promise(async () => {
|
||||
await Filesystem.write(path.join(dir, "big.txt"), before)
|
||||
})
|
||||
yield* Effect.promise(() => commit(dir, "v1"))
|
||||
yield* Effect.promise(async () => {
|
||||
await Filesystem.write(path.join(dir, "big.txt"), after)
|
||||
})
|
||||
|
||||
const start = Date.now()
|
||||
const got = yield* DiffFull.file(gitText(dir), "big.txt")
|
||||
const elapsed = Date.now() - start
|
||||
expect(got).not.toBeNull()
|
||||
expect(got!.patch.hunks.length).toBeGreaterThan(0)
|
||||
expect(elapsed).toBeLessThan(500)
|
||||
}),
|
||||
10_000,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,408 @@
|
||||
// Regressions for the MAX_COMPACTION_ATTEMPTS cap in SessionPrompt.runLoop.
|
||||
// Ensures the loop cannot spin forever when every compaction round still
|
||||
// overflows the model context, and that the exhausted turn surfaces as an
|
||||
// error (rather than silently completing).
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
import { Config } from "../../src/config/config"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Env } from "../../src/env"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { KiloSession } from "../../src/kilocode/session"
|
||||
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Question } from "../../src/question"
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionProcessor } from "../../src/session/processor"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionRevert } from "../../src/session/revert"
|
||||
import { SessionRunState } from "../../src/session/run-state"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { SystemPrompt } from "../../src/session/system"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { Todo } from "../../src/session/todo"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderID.make("test"),
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
const summary = Layer.succeed(
|
||||
SessionSummary.Service,
|
||||
SessionSummary.Service.of({
|
||||
summarize: () => Effect.void,
|
||||
diff: () => Effect.succeed([]),
|
||||
computeDiff: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
// Pass-through plugin mock. Lets every plugin trigger proceed with its default
|
||||
// output so compaction's `experimental.compaction.autocontinue` stays on (the
|
||||
// "compact" result path uses replay mode and the loop re-enters without the
|
||||
// synthetic continue prompt anyway).
|
||||
const plugin = Layer.mock(Plugin.Service)({
|
||||
trigger: <Name extends string, Input, Output>(_name: Name, _input: Input, output: Output) => Effect.succeed(output),
|
||||
list: () => Effect.succeed([]),
|
||||
init: () => Effect.void,
|
||||
})
|
||||
|
||||
const mcp = Layer.succeed(
|
||||
MCP.Service,
|
||||
MCP.Service.of({
|
||||
status: () => Effect.succeed({}),
|
||||
clients: () => Effect.succeed({}),
|
||||
tools: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
|
||||
connect: () => Effect.void,
|
||||
disconnect: () => Effect.void,
|
||||
getPrompt: () => Effect.succeed(undefined),
|
||||
readResource: () => Effect.succeed(undefined),
|
||||
startAuth: () => Effect.die("unexpected MCP auth in compaction cap tests"),
|
||||
authenticate: () => Effect.die("unexpected MCP auth in compaction cap tests"),
|
||||
finishAuth: () => Effect.die("unexpected MCP auth in compaction cap tests"),
|
||||
removeAuth: () => Effect.void,
|
||||
supportsOAuth: () => Effect.succeed(false),
|
||||
hasStoredTokens: () => Effect.succeed(false),
|
||||
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
|
||||
}),
|
||||
)
|
||||
|
||||
const lsp = Layer.succeed(
|
||||
LSP.Service,
|
||||
LSP.Service.of({
|
||||
init: () => Effect.void,
|
||||
status: () => Effect.succeed([]),
|
||||
hasClients: () => Effect.succeed(false),
|
||||
touchFile: () => Effect.void,
|
||||
diagnostics: () => Effect.succeed({}),
|
||||
hover: () => Effect.succeed(undefined),
|
||||
definition: () => Effect.succeed([]),
|
||||
references: () => Effect.succeed([]),
|
||||
implementation: () => Effect.succeed([]),
|
||||
documentSymbol: () => Effect.succeed([]),
|
||||
workspaceSymbol: () => Effect.succeed([]),
|
||||
prepareCallHierarchy: () => Effect.succeed([]),
|
||||
incomingCalls: () => Effect.succeed([]),
|
||||
outgoingCalls: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
const filetime = Layer.succeed(
|
||||
FileTime.Service,
|
||||
FileTime.Service.of({
|
||||
read: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
assert: () => Effect.void,
|
||||
withLock: (_filepath, fn) => fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||
const runState = SessionRunState.layer.pipe(Layer.provide(status))
|
||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||
|
||||
function makeHttp() {
|
||||
const deps = Layer.mergeAll(
|
||||
Session.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Env.defaultLayer,
|
||||
AgentSvc.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
plugin,
|
||||
Config.defaultLayer,
|
||||
ProviderSvc.defaultLayer,
|
||||
filetime,
|
||||
lsp,
|
||||
mcp,
|
||||
AppFileSystem.defaultLayer,
|
||||
status,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
)
|
||||
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||
const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
|
||||
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||
return Layer.mergeAll(
|
||||
TestLLMServer.layer,
|
||||
SessionPrompt.layer.pipe(
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(summary),
|
||||
Layer.provideMerge(runState),
|
||||
Layer.provideMerge(compact),
|
||||
Layer.provideMerge(proc),
|
||||
Layer.provideMerge(registry),
|
||||
Layer.provideMerge(trunc),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(SystemPrompt.defaultLayer),
|
||||
Layer.provideMerge(deps),
|
||||
),
|
||||
).pipe(Layer.provide(summary))
|
||||
}
|
||||
|
||||
const it = testEffect(makeHttp())
|
||||
|
||||
const cfg = {
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: "http://localhost:1/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function providerCfg(url: string) {
|
||||
return {
|
||||
...cfg,
|
||||
provider: {
|
||||
...cfg.provider,
|
||||
test: {
|
||||
...cfg.provider.test,
|
||||
options: {
|
||||
...cfg.provider.test.options,
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const overflowBody = { type: "error", error: { code: "context_length_exceeded" } }
|
||||
|
||||
describe("session compaction cap", () => {
|
||||
it.live(
|
||||
"closes the turn with reason=error after MAX_COMPACTION_ATTEMPTS compactions",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Compaction cap",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
// Interleave overflow errors (top-level LLM call) and successful
|
||||
// summary texts (compaction.process internal LLM call) so each
|
||||
// compaction round completes and the loop re-enters. With
|
||||
// MAX_COMPACTION_ATTEMPTS = 3 we queue 4 errors + 3 texts = 7 calls.
|
||||
// The final error triggers guardCompactionAttempt and breaks.
|
||||
yield* llm.error(400, overflowBody) // 1 — top-level fails → attempt 1
|
||||
yield* llm.text("summary 1") // 2 — compaction summary succeeds
|
||||
yield* llm.error(400, overflowBody) // 3 — post-replay fails → attempt 2
|
||||
yield* llm.text("summary 2") // 4
|
||||
yield* llm.error(400, overflowBody) // 5 — attempt 3
|
||||
yield* llm.text("summary 3") // 6
|
||||
yield* llm.error(400, overflowBody) // 7 — exhausts, breaks
|
||||
|
||||
const turnClose = yield* Deferred.make<KiloSession.CloseReason>()
|
||||
const unsub = yield* bus.subscribeCallback(KiloSession.Event.TurnClose, (evt) => {
|
||||
if (evt.properties.sessionID === chat.id)
|
||||
Deferred.doneUnsafe(turnClose, Effect.succeed(evt.properties.reason))
|
||||
})
|
||||
|
||||
yield* prompt.prompt({
|
||||
sessionID: chat.id,
|
||||
agent: "code",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "please overflow" }],
|
||||
})
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
const reason = yield* Deferred.await(turnClose).pipe(Effect.timeout("2 seconds"))
|
||||
unsub()
|
||||
|
||||
// Each compaction round costs 2 LLM calls in this replay-mode path (one
|
||||
// top-level overflow + one summary) plus 1 final overflow that trips the cap.
|
||||
expect(yield* llm.calls).toBe(KiloSessionPrompt.MAX_COMPACTION_ATTEMPTS * 2 + 1)
|
||||
expect(reason).toBe("error")
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role !== "assistant") return
|
||||
expect(result.info.finish).toBe("error")
|
||||
expect(result.info.error?.name).toBe("ContextOverflowError")
|
||||
if (result.info.error?.name !== "ContextOverflowError") return
|
||||
expect(result.info.error.data.message).toContain("Compaction exhausted")
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"completes normally when compactions stay below the cap",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Compaction under cap",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
yield* llm.error(400, overflowBody) // 1 — one compaction attempt
|
||||
yield* llm.text("summary ok") // 2 — summary succeeds
|
||||
yield* llm.text("final answer") // 3 — replayed turn completes
|
||||
|
||||
const turnClose = yield* Deferred.make<KiloSession.CloseReason>()
|
||||
const unsub = yield* bus.subscribeCallback(KiloSession.Event.TurnClose, (evt) => {
|
||||
if (evt.properties.sessionID === chat.id)
|
||||
Deferred.doneUnsafe(turnClose, Effect.succeed(evt.properties.reason))
|
||||
})
|
||||
|
||||
yield* prompt.prompt({
|
||||
sessionID: chat.id,
|
||||
agent: "code",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "overflow once" }],
|
||||
})
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
const reason = yield* Deferred.await(turnClose).pipe(Effect.timeout("2 seconds"))
|
||||
unsub()
|
||||
|
||||
expect(yield* llm.calls).toBe(3)
|
||||
expect(reason).toBe("completed")
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role !== "assistant") return
|
||||
expect(result.info.finish).toBe("stop")
|
||||
expect(result.info.error).toBeUndefined()
|
||||
expect(result.parts.some((p) => p.type === "text" && p.text === "final answer")).toBe(true)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
15_000,
|
||||
)
|
||||
})
|
||||
|
||||
function makeAssistantStub(sessionID: string): MessageV2.Assistant {
|
||||
return {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
sessionID: SessionID.make(sessionID),
|
||||
parentID: MessageID.ascending(),
|
||||
mode: "code",
|
||||
agent: "code",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: ref.modelID,
|
||||
providerID: ref.providerID,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
describe("KiloSessionPrompt.guardCompactionAttempt", () => {
|
||||
it.effect("returns { exhausted: false } and does not mutate state below the cap", () =>
|
||||
Effect.sync(() => {
|
||||
const closeReasons = new Map<string, KiloSession.CloseReason>()
|
||||
const msg = makeAssistantStub("ses_under")
|
||||
const result = KiloSessionPrompt.guardCompactionAttempt({
|
||||
sessionID: "ses_under",
|
||||
attempts: KiloSessionPrompt.MAX_COMPACTION_ATTEMPTS - 1,
|
||||
closeReasons,
|
||||
message: msg,
|
||||
})
|
||||
expect(result.exhausted).toBe(false)
|
||||
expect(closeReasons.has("ses_under")).toBe(false)
|
||||
expect(msg.error).toBeUndefined()
|
||||
expect(msg.finish).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sets close reason and attaches error once attempts reach the cap", () =>
|
||||
Effect.sync(() => {
|
||||
const closeReasons = new Map<string, KiloSession.CloseReason>()
|
||||
const msg = makeAssistantStub("ses_cap")
|
||||
const result = KiloSessionPrompt.guardCompactionAttempt({
|
||||
sessionID: "ses_cap",
|
||||
attempts: KiloSessionPrompt.MAX_COMPACTION_ATTEMPTS,
|
||||
closeReasons,
|
||||
message: msg,
|
||||
})
|
||||
expect(result.exhausted).toBe(true)
|
||||
if (!result.exhausted) return
|
||||
expect(closeReasons.get("ses_cap")).toBe("error")
|
||||
expect(msg.error?.name).toBe("ContextOverflowError")
|
||||
if (msg.error?.name !== "ContextOverflowError") return
|
||||
expect(msg.error.data.message).toContain("Compaction exhausted")
|
||||
expect(msg.finish).toBe("error")
|
||||
expect(result.error.name).toBe("ContextOverflowError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("works without a message and still sets the close reason", () =>
|
||||
Effect.sync(() => {
|
||||
const closeReasons = new Map<string, KiloSession.CloseReason>()
|
||||
const result = KiloSessionPrompt.guardCompactionAttempt({
|
||||
sessionID: "ses_no_msg",
|
||||
attempts: KiloSessionPrompt.MAX_COMPACTION_ATTEMPTS,
|
||||
closeReasons,
|
||||
})
|
||||
expect(result.exhausted).toBe(true)
|
||||
expect(closeReasons.get("ses_no_msg")).toBe("error")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
// kilocode_change - new file
|
||||
//
|
||||
// Regression test for the freeze bug: before the caps + worker offload,
|
||||
// Snapshot.diffFull on a file with tens of thousands of lines could block
|
||||
// the thread for minutes. In the TUI, that same thread hosts the Hono
|
||||
// server — so the POST /:id/abort endpoint (what ESC fires) never ran.
|
||||
//
|
||||
// This test proves:
|
||||
// 1. A synthetic freeze workload (30k-line file) now completes quickly.
|
||||
// 2. The abort endpoint responds within a bounded time while the freeze
|
||||
// workload runs concurrently.
|
||||
// 3. A concurrent setInterval keeps ticking — i.e. the event loop keeps
|
||||
// breathing and ESC would be delivered.
|
||||
|
||||
import { test, expect, afterEach, mock } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session } from "../../src/session"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("pathological diffFull workload finishes quickly and does not block abort", async () => {
|
||||
// 3000-line file that churns every line between snapshots. Before the fix
|
||||
// this ran through structuredPatch at context=MAX_SAFE_INTEGER synchronously
|
||||
// and could take minutes.
|
||||
const v1 = Array.from({ length: 3000 }, (_, i) => `v1_line_${i}`).join("\n") + "\n"
|
||||
const v2 = Array.from({ length: 3000 }, (_, i) => `v2_line_${i}`).join("\n") + "\n"
|
||||
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(`${dir}/fat.json`, v1)
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Filesystem.write(`${tmp.path}/fat.json`, v2)
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
|
||||
// Kick off a diffFull that exercises the freeze path.
|
||||
const diffPromise = Snapshot.diffFull(before!, after!)
|
||||
|
||||
// Concurrently keep a tick counter running. If the event loop blocks we
|
||||
// will see this count fall behind wall-clock elapsed.
|
||||
let ticks = 0
|
||||
const start = Date.now()
|
||||
const timer = setInterval(() => {
|
||||
ticks++
|
||||
}, 25)
|
||||
|
||||
// Fire an abort request against the Hono app in the middle of the diff.
|
||||
const app = Server.Default().app
|
||||
const abortStart = Date.now()
|
||||
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
|
||||
const abortLatency = Date.now() - abortStart
|
||||
expect(res.status).toBe(200)
|
||||
// The abort endpoint must respond well under a second even under load.
|
||||
expect(abortLatency).toBeLessThan(2000)
|
||||
|
||||
const diffs = await diffPromise
|
||||
clearInterval(timer)
|
||||
const total = Date.now() - start
|
||||
|
||||
// The freeze workload must finish in bounded time. Five seconds is
|
||||
// generous even for a slow CI box; without the fix this hangs.
|
||||
expect(total).toBeLessThan(5000)
|
||||
// And we must have ticked at least a few times during the work — proves
|
||||
// the event loop stayed responsive (ESC would actually arrive).
|
||||
expect(ticks).toBeGreaterThan(0)
|
||||
|
||||
// With git-based diff the patch is a real unified diff, not empty.
|
||||
const hit = diffs.find((d) => d.file === "fat.json")
|
||||
expect(hit).toBeDefined()
|
||||
expect(hit!.patch).toMatch(/^diff --git /m)
|
||||
expect(hit!.patch).toContain("-v1_line_0")
|
||||
expect(hit!.patch).toContain("+v2_line_0")
|
||||
expect(hit!.additions).toBeGreaterThan(0)
|
||||
expect(hit!.deletions).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"dependencies": {},
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/upstream-merge",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Scripts for automating upstream opencode merges into Kilo",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "7.2.17",
|
||||
"version": "7.2.20",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user